diff --git a/docs/ai/design/2026-08-23-feature-status-cmd.md b/docs/ai/design/2026-08-23-feature-status-cmd.md new file mode 100644 index 00000000..06bd4f47 --- /dev/null +++ b/docs/ai/design/2026-08-23-feature-status-cmd.md @@ -0,0 +1,271 @@ +--- +phase: design +title: AI DevKit Status Command Design +description: Architecture and contracts for the read-only setup readiness report +feature: status-command +--- + +# Design: AI DevKit Status Command + +## Architecture Overview + +`ai-devkit status` is a CLI-owned diagnostic aggregator. It reads local setup state through injected filesystem and process boundaries, reuses existing authoritative constants and the Codex capacity/auth probe, derives normalized check statuses, and then sends one canonical report to either JSON or human rendering. + +```mermaid +flowchart LR + CLI[status command] --> S[Status service] + S --> P[Project and registry checks] + S --> A[Per-agent checks] + S --> T[tmux check] + S --> C[Channel config check] + S --> V[Version check] + A --> E[PATH and global dirs] + A --> K[Built-in skills] + A --> H[Hooks and mappings] + A --> U[Auth probes] + S --> R[Canonical StatusReport] + R --> J[JSON renderer] + R --> HUI[Human ui.table renderer] +``` + +### Module layout + +- `packages/cli/src/commands/status.ts` + - Registers `status` and `-j, --json`. + - Calls the status service through `withErrorHandler`. + - Contains no check logic. +- `packages/cli/src/services/status/status.service.ts` + - Orchestrates all independent checks. + - Defines injectable runtime dependencies for filesystem, executable, subprocess, npm, clock, and Codex auth boundaries. + - Aggregates leaf statuses and counts. +- `packages/cli/src/services/status/status.types.ts` + - Defines the canonical report and nested check contracts. + - Exports `CheckStatus = 'pass' | 'warn' | 'fail'` and `AuthState = 'authenticated' | 'unauthenticated' | 'unknown'`. +- `packages/cli/src/services/status/status.helpers.ts` + - Small pure helpers for status aggregation, JSON-record validation, safe path display, asset comparison, and secret-safe errors. +- `packages/cli/src/commands/status/render.ts` + - Emits exact JSON with `JSON.stringify(report, null, 2)`. + - Renders the same report for humans using shared `ui.text`, `ui.table`, and established chalk conventions. +- `packages/cli/src/__tests__/services/status/status.service.test.ts` + - Unit tests with isolated temporary homes/projects and injected process/network probes. +- `packages/cli/src/__tests__/commands/status.test.ts` + - Registration, JSON contract, rendering, and nonfatal multi-failure tests. + +No new agent-manager abstraction is planned. The existing `getCodexCapacityReport` API already provides the required safe Codex authentication signal. Status maps only its `authenticated` field and discards capacity windows, credits, and availability. + +## Check-to-Source Mapping + +| Requirement | Implementation | Authoritative source | +|---|---|---| +| FR-01 executables | Resolve `codex`, `pi`, `claude` by scanning `PATH` for executable files | Agent command names from `AGENTS` in `@ai-devkit/agent-manager` | +| FR-02 global dirs | `fs.access` against `~/.codex`, `~/.pi`, `~/.claude` | Setup definitions in `setup.service.ts` | +| FR-03 built-in skills | Compare `BUILTIN_SKILL_NAMES` with `//SKILL.md` | `constants.ts` and `ENVIRONMENT_DEFINITIONS` | +| FR-04 Codex hook | Compare installed script bytes with bundled asset; parse `hooks.json`; validate mapping record | CLI setup assets and `CodexAdapter` mapping path | +| FR-04 Claude hook | Compare installed script bytes with bundled asset; parse `settings.json` registration | CLI setup assets and setup service command constant | +| FR-04 Pi tracker | Use read-only `pi list` output to detect package; validate sessions registry | Setup service install package and `PiAdapter` tracker path | +| FR-05 auth | Codex capacity probe auth field; `claude auth status --json`; structural Pi auth file check | Existing Codex probe and provider-owned local commands/files | +| FR-06 tmux | Resolve executable and run `tmux -V` | Existing `TmuxManager.isAvailable` behavior | +| FR-07 channels | Read raw `~/.ai-devkit/channels.json` and validate supported entry shapes locally | Channel connector types and connection-time credential rules | +| FR-08 registries | Parse project and global config independently and normalize string records | `ConfigManager`, `GlobalConfigManager`, `filterStringRecord` | +| FR-09 versions | Installed CLI package metadata plus injected npm-version reader using `npm view ai-devkit version` | CLI `package.json` and npm registry | +| FR-10 project config | Parse `.ai-devkit.json`; validate object shape and environment codes | `DevKitConfig` and `isValidEnvironmentCode` | + +## Data Model + +```ts +type CheckStatus = 'pass' | 'warn' | 'fail'; +type AuthState = 'authenticated' | 'unauthenticated' | 'unknown'; + +interface StatusReport { + generatedAt: string; + overall: CheckStatus; + aiDevkit: VersionCheck; + project: { cwd: string; config: ProjectConfigCheck }; + agents: { + codex: AgentStatusCheck; + pi: AgentStatusCheck; + claude: AgentStatusCheck; + }; + tmux: TmuxCheck; + registries: RegistriesCheck; + channels: ChannelsCheck; + checks: { passed: number; warnings: number; failed: number }; +} +``` + +### Per-agent contract + +Each agent contains: + +- `executable`: command, resolved path or `null`, status, safe errors. +- `globalConfig`: display path, presence, readability, status. +- `auth`: `state`, evidence source, status, safe errors. +- `builtInSkills`: display path, required count, present count, missing array, status. +- `hooks`: agent-specific nested checks plus an aggregate status. +- `status`: worst status across the agent's checks. + +Agent-specific hook data remains structurally distinct: + +- Codex: mapping script, registration, and mapping file health. +- Claude: prompt script and registration. +- Pi: tracker registration and sessions registry health. + +### Status aggregation + +Statuses are ordered `pass < warn < fail`. Every leaf check is counted once. Aggregate objects use the worst child status but are not counted again, preventing inflated totals. `overall` is the worst leaf status. + +Absence semantics are explicit: + +- Missing required executable, global directory, required built-in skill, or registered hook: `fail`. +- Missing Codex/Pi session mapping registry before any session: `warn`. +- Npm lookup unavailable: `warn` with `latestVersion` and `updateAvailable` set to `null`. +- Missing project config: reportable `fail`, not a thrown error. +- Pi auth file that is structurally valid: `unknown` auth with `warn`, because presence does not prove credential validity. + +## API and Command Design + +```text +ai-devkit status +ai-devkit status --json +ai-devkit status -j +``` + +Registration follows other top-level commands: + +```ts +registerStatusCommand(program); +``` + +The service API is: + +```ts +getStatusReport(options?: StatusServiceOptions): Promise +``` + +`StatusServiceOptions` accepts `cwd`, `homeDir`, `path`, `now`, and dependency overrides. Production defaults use Node APIs; tests supply controlled implementations. The report never contains dependency objects or raw command output. + +### Canonical JSON shape + +```json +{ + "generatedAt": "2026-08-23T00:00:00.000Z", + "overall": "warn", + "aiDevkit": { + "installedVersion": "0.55.0", + "latestVersion": null, + "updateAvailable": null, + "latestVersionSource": "npm", + "status": "warn", + "errors": ["npm registry unavailable"] + }, + "project": { + "cwd": "/repo", + "config": { + "path": "/repo/.ai-devkit.json", + "present": true, + "valid": true, + "version": "0.55.0", + "environments": ["codex"], + "errors": [], + "status": "pass" + } + }, + "agents": { + "codex": {}, + "pi": {}, + "claude": {} + }, + "tmux": {}, + "registries": {}, + "channels": {}, + "checks": { "passed": 0, "warnings": 0, "failed": 0 } +} +``` + +Arrays stay present when empty. Meaningfully unavailable scalar values are `null`. Paths are absolute for the current project and `~/...` display paths for user-global state. The report has no schema-version field until a real compatibility migration requires one. + +## Component Behavior + +### Independent probe orchestration + +The service starts logically independent checks together with `Promise.all`. Each check catches expected I/O, parse, subprocess, and network errors at its own boundary and returns a finding. Only programmer errors that prevent report construction escape to `withErrorHandler`. + +### Files and hook health + +- File reads are bounded to known configuration, hook, auth-structure, and registry paths. +- Hook equality uses exact bytes against packaged assets; hook code is never executed. +- Registration parsing accepts unrelated provider settings while requiring the exact AI DevKit command entry. +- Mapping validators count entries and stale paths without returning PID keys, mapped file names, or contents. + +### Authentication + +- Codex calls `getCodexCapacityReport` and maps `true`, `false`, or `null` to the three auth states. Capacity fields are ignored. +- Claude executes only `claude auth status --json`, applies a timeout, parses the documented status field defensively, and replaces all raw failures with fixed safe messages. +- Pi reads and structurally validates `~/.pi/agent/auth.json`; it never returns keys or values and never claims authenticated from presence alone. + +### Channels + +The status service does not use `ChannelConfigRepository.getConfig()` because that method intentionally converts missing and corrupt files into the same empty configuration. Status needs to distinguish those states, so it reads the known repository path directly and validates a secret-free projection. Telegram and Slack readiness follow current connection-time field requirements. Unsupported entry types fail schema validation without echoing their configuration. + +### Human rendering + +Human output is a projection of `StatusReport`, not a second execution path: + +- `ui.text` introduces sections. +- `ui.table` renders compact rows for AI DevKit/project, agents, tmux, registries, and channels. +- `chalk.green`, `chalk.yellow`, and `chalk.red` represent pass, warn, and fail; identifiers use cyan and supporting evidence uses dim text, matching capacity and agent-list conventions. +- Missing built-ins and safe errors appear as short follow-up lines. +- No renderer receives raw secrets or raw provider output. + +## Design Decisions + +1. **CLI-owned aggregator rather than agent-manager-owned status framework.** Most checks concern CLI assets and configuration. Only the existing Codex auth/capacity boundary is shared. This keeps deletion cost low and avoids a speculative provider abstraction. +2. **Canonical report first, render second.** Both output modes consume the same object, preventing behavior drift and making agent-manager parsing deterministic. +3. **Injected boundaries rather than global mocks.** Tests can prove missing files, malformed JSON, command failures, and npm failures without reading real user state or accessing networks. +4. **Direct channel config read.** Required to distinguish absent and malformed files; the operational repository's fallback behavior remains unchanged. +5. **Fixed safe errors.** Raw provider and subprocess errors are never emitted because they may contain credentials or command output. +6. **No automatic remediation.** The service does not reuse setup writers or registry fetchers; it only reads and reports. + +## Alternatives Rejected + +- Calling and parsing existing CLI command output: duplicates rendering contracts and makes failures harder to isolate. +- Putting all checks in agent-manager: couples project config, channels, npm, and CLI assets to the agent runtime package. +- Generic provider plugin/check registry: no current caller beyond three fixed agents. +- Live auth validation for Pi: no verified read-only provider probe exists; structural evidence remains `unknown`. +- Live channel credential validation: violates the approved local-only readiness boundary. +- Returning raw capacity results for Codex: explicitly outside scope. + +## Non-Functional Requirements + +### Security + +- Never serialize credential values, file contents, session identifiers, mapping keys, raw auth responses, or raw subprocess/network errors. +- Use fixed error codes/messages at sensitive boundaries. +- Never execute installed hook scripts. +- Apply short timeouts to Claude auth, Pi package listing, tmux, and npm subprocesses. + +### Reliability + +- Every independent check completes even when siblings fail. +- Missing or malformed user state produces a report rather than an exception. +- The report is deterministic under injected dependencies and fixed time. + +### Performance + +- Filesystem checks may run concurrently. +- External subprocesses are bounded and run concurrently where independent. +- No registry fetch, skill-index scan, session traversal, channel network call, or live-agent discovery occurs. + +### Compatibility and rollout + +- The change adds a top-level command without altering existing command behavior or public package types. +- Existing capacity, setup, channel, skill, and agent commands remain authoritative for their detailed operations. +- The first release establishes the JSON contract through unit and CLI tests; future incompatible changes require explicit compatibility design. + +## Requirements Coverage + +FR-01 through FR-10 map directly to the check table and service components above. AC-11 and AC-12 are covered by canonical aggregation and isolated probes; AC-13 is covered by safe projections and secret-sentinel tests; AC-14 is covered by dependency assertions proving forbidden live/mutating APIs are never called. + +## Questions & Open Items + +- None. The architecture, data contract, source mapping, rendering approach, security boundary, and rejected alternatives are resolved for planning. diff --git a/docs/ai/implementation/2026-08-23-feature-status-cmd.md b/docs/ai/implementation/2026-08-23-feature-status-cmd.md new file mode 100644 index 00000000..ca984ac9 --- /dev/null +++ b/docs/ai/implementation/2026-08-23-feature-status-cmd.md @@ -0,0 +1,108 @@ +--- +phase: implementation +title: AI DevKit Status Command Implementation +description: Implementation record for canonical setup and readiness reporting +feature: status-command +--- + +# Implementation: AI DevKit Status Command + +## Development Setup + +- Active worktree: `.worktrees/feature-status-cmd`. +- Branch: `feature-status-cmd`, based on the AI DevKit 0.55.0 release. +- Dependency bootstrap: `npm ci`. +- Workspace build: `npm run build` for all six projects. +- Test temporary files are redirected to `~/.ai-devkit/status-command-tmp` because the shared `/tmp` user quota is exhausted. +- Optional lifecycle task tracing is unavailable in CLI 0.55.0 (`unknown command 'task'`). + +## Code Structure + +- `packages/cli/src/services/status/status.service.ts` + - Canonical `StatusReport`, nested check types, status aggregation, and all read-only probes. + - Injected cwd, home, PATH, time, filesystem, subprocess, asset, installed-version, and Codex-auth boundaries. +- `packages/cli/src/commands/status.ts` + - Commander registration for `status` and `-j, --json` through `withErrorHandler`. +- `packages/cli/src/commands/status/render.ts` + - Exact JSON output and human rendering through shared `ui.table`/chalk conventions. +- `packages/cli/src/cli.ts` + - Registers the new top-level command before configured plugin commands. +- `packages/cli/src/__tests__/services/status/status.service.test.ts` + - Deterministic service fixtures and multi-failure/security cases. +- `packages/cli/src/__tests__/commands/status.test.ts` + - Canonical JSON, human table, and Commander wiring tests. + +## Implementation Notes + +### Canonical report and aggregation + +- `getStatusReport` runs independent project, agent, tmux, registry, channel, and version probes concurrently. +- Every expected I/O, parse, subprocess, provider, and network failure becomes a fixed safe finding. +- Leaf statuses are counted exactly once; aggregates and `overall` use `pass < warn < fail` precedence. +- Empty arrays and meaningful `null` values remain explicit. + +### Per-agent checks + +- Executable lookup uses canonical `AGENTS` command names and execute-permission checks across `PATH`. +- Global directories and built-in skills use AI DevKit environment definitions and `BUILTIN_SKILL_NAMES`. +- Codex and Claude hook scripts are compared byte-for-byte with bundled assets without execution. +- Hook configuration parsers require the exact approved event/command registrations while ignoring unrelated provider settings. +- Codex and Pi mapping registries validate PID/path structure and count stale referenced paths without returning mapping keys or contents. +- Codex maps only the existing capacity probe's authentication signal; capacity data is discarded. +- Claude invokes only `claude auth status --json`. +- Pi credential-file structure yields `unknown`, never an unverified authenticated claim. + +### Shared subsystem checks + +- tmux resolves on `PATH` and runs only `tmux -V`. +- Channel configuration is read directly so missing, malformed, root-invalid, entry-invalid, disabled, and ready states remain distinguishable. +- Telegram and Slack readiness is local-only; tokens are reduced to booleans and never serialized. +- Project and global registries retain provenance; URL credentials, queries, and fragments are removed before serialization, while unrecognized URL forms are redacted. +- Npm latest lookup is bounded behind the injected subprocess dependency and degrades to warning/null values. +- Project configuration validates JSON object shape, version, environment array, and canonical environment codes. + +## TDD Record + +1. **Red:** service test failed because `status.service` did not exist. +2. **Green:** canonical service added; 3 service tests passed. +3. **Red:** command test failed because `commands/status` did not exist. +4. **Green:** command, renderer, and registration added; combined suites passed after completing the test fixture. +5. **Refactor:** added execute-permission and invalid channel-entry assertions; 7 combined tests pass. +6. **Review fix:** made bundled-asset discovery work from both source and compiled module layouts, and added registry-secret regression coverage; 7 combined tests pass. + +## Design Alignment and Deviations + +- The implementation follows the designed CLI-owned aggregator and reuses agent-manager only for its existing Codex auth signal. +- Types, pure helpers, and probes are colocated in `status.service.ts` rather than split across `status.types.ts` and `status.helpers.ts`. This is a deliberate smaller implementation: there is one caller and no demonstrated reuse requiring extra modules. +- Pi tracker discovery uses the designed injected `pi list` subprocess boundary. +- Human rendering uses the shared terminal UI and the canonical report. +- No requirements scope was added or removed. + +## Error Handling + +- Raw filesystem, subprocess, npm, auth, and provider errors are never placed in the report. +- Registry URL user information, query strings, and fragments are never placed in the report. +- Missing and malformed local state is returned as findings and does not stop sibling probes. +- Only inability to construct or serialize the report escapes to the command error handler. + +## Security Notes + +- Credential and session files are parsed only into safe booleans/counts. +- Mapping PID keys, session paths, credential values, channel tokens, and raw provider failures are not returned. +- Hook scripts are read for equality and never executed. +- Channel checks do not call Telegram or Slack. +- The only allowed network-capable boundaries are the approved Codex auth probe and npm latest-version query. + +## Validation + +- Targeted status tests: 2 files, 7 tests passed. +- Six-project build: passed after final review fixes. +- Full repository tests: 1,984 tests passed across six projects. +- Repository lint: zero errors; four pre-existing unused-catch warnings outside changed files. +- Built JSON smoke test: parsed successfully with `codex`, `pi`, and `claude` keys and normalized overall status. +- Base and feature lifecycle lint: passed. + +## Follow-ups + +- Publish the reviewed branch and open the pull request without merging it. +- No product or implementation follow-up is currently identified. diff --git a/docs/ai/planning/2026-08-23-feature-status-cmd.md b/docs/ai/planning/2026-08-23-feature-status-cmd.md new file mode 100644 index 00000000..a11e5a0f --- /dev/null +++ b/docs/ai/planning/2026-08-23-feature-status-cmd.md @@ -0,0 +1,182 @@ +--- +phase: planning +title: AI DevKit Status Command Plan +description: Ordered TDD implementation and validation tasks for status readiness reporting +feature: status-command +--- + +# Planning: AI DevKit Status Command + +## Milestones + +- [x] Milestone 1: Requirements approved and committed. +- [x] Milestone 2: Architecture and output contract designed. +- [x] Milestone 3: Canonical status service implemented through TDD. +- [x] Milestone 4: CLI registration and human/JSON rendering implemented. +- [x] Milestone 5: Documentation, full validation, and final review completed. +- [ ] Milestone 6: Branch published and pull request opened. + +## Ordered Task Breakdown + +### Phase 1: Canonical model and pure behavior + +- [x] **Task 1.1 — Define report types and aggregation helpers** + - Outcome: typed per-agent/subsystem report with `pass | warn | fail`, auth states, deterministic leaf counts, and worst-status aggregation. + - Dependencies: requirements output contract and design data model. + - TDD: red tests for status precedence, leaf counting, empty arrays, and explicit nulls. + - Evidence: targeted status service tests and TypeScript build. + - Covers: AC-11, AC-12. + +- [x] **Task 1.2 — Add injectable status runtime boundary** + - Outcome: service accepts cwd/home/PATH/time plus filesystem, command, npm, asset, and Codex-auth dependencies. + - Dependencies: Task 1.1. + - TDD: red test builds a complete report without reading the real machine. + - Evidence: targeted service test proves deterministic output and independent probes. + - Covers: AC-12, AC-13, AC-14. + +### Phase 2: Local agent readiness checks + +- [x] **Task 2.1 — Executables and global configuration directories** + - Outcome: Codex, Pi, and Claude report resolved executable paths plus directory presence/readability. + - Dependencies: Task 1.2; reuse `AGENTS` command names and environment path definitions. + - TDD: mixed-present/missing executables and directories. + - Evidence: targeted tests. + - Covers: AC-01, AC-02. + +- [x] **Task 2.2 — Built-in skills per agent** + - Outcome: compare `BUILTIN_SKILL_NAMES` against three global skill roots, returning counts and missing names only. + - Dependencies: Task 1.2. + - TDD: complete, partial, and absent skill roots; assert no skill-index dependency. + - Evidence: targeted tests. + - Covers: AC-03, AC-14. + +- [x] **Task 2.3 — Codex, Claude, and Pi integration hooks** + - Outcome: validate installed assets and registrations; validate Codex/Pi mapping files with missing/malformed/stale distinctions. + - Dependencies: Task 1.2 and packaged assets. + - TDD: correct/missing/mismatched scripts, unrelated hook preservation, malformed JSON, invalid entries, stale paths, absent registries. + - Evidence: targeted tests. + - Covers: AC-04, AC-12, AC-13. + +- [x] **Task 2.4 — Authentication state** + - Outcome: map safe Codex auth result, parse Claude auth status, and structurally evaluate Pi auth without overclaiming validity. + - Dependencies: Task 1.2 and existing Codex capacity API. + - TDD: authenticated/unauthenticated/unknown, timeouts, malformed outputs, secret-sentinel failures. + - Evidence: targeted tests proving no credential/raw error output. + - Covers: AC-05, AC-13, AC-14. + +### Phase 3: Shared subsystem checks + +- [x] **Task 3.1 — tmux readiness** + - Outcome: resolved path and `tmux -V` result without requiring a server. + - Dependencies: executable helper. + - TDD: installed, absent, and command-failure cases. + - Evidence: targeted tests. + - Covers: AC-06. + +- [x] **Task 3.2 — Channel config validity and local readiness** + - Outcome: distinguish absent/malformed/root-invalid config and validate secret-free Telegram/Slack projections. + - Dependencies: Task 1.2 and channel connector types/rules. + - TDD: ready/unready/disabled entries, malformed tokens, missing identity/authorization, unsupported types, secret sentinels, and assertion that no live connector runs. + - Evidence: targeted tests. + - Covers: AC-07, AC-13, AC-14. + +- [x] **Task 3.3 — Project config and registries** + - Outcome: report project config presence/structure/environment validity and project/global registry provenance. + - Dependencies: canonical environment validators and registry normalization helper. + - TDD: absent, malformed, invalid environment, valid config, malformed global config, and mixed registry values. + - Evidence: targeted tests. + - Covers: AC-08, AC-10, AC-12. + +- [x] **Task 3.4 — Installed/latest version** + - Outcome: compare installed package version with npm latest; npm failures yield warning/nulls. + - Dependencies: injected command boundary and semver-safe equality/order. + - TDD: same/newer/latest and npm unavailable/invalid output. + - Evidence: targeted tests. + - Covers: AC-09, AC-12. + +### Phase 4: Command and rendering + +- [x] **Task 4.1 — Register `status` command** + - Outcome: top-level command supports `-j, --json`, uses `withErrorHandler`, and invokes one report reader. + - Dependencies: completed service. + - TDD: Commander registration and dependency invocation. + - Evidence: command test. + - Covers: command UX and AC-11. + +- [x] **Task 4.2 — Canonical JSON renderer** + - Outcome: exact pretty JSON with no alternate transformation. + - Dependencies: Task 4.1. + - TDD: captured stdout deep-equals supplied report and secret sentinel is absent. + - Evidence: command test. + - Covers: AC-11, AC-13. + +- [x] **Task 4.3 — Human renderer** + - Outcome: shared `ui.table` sections use cyan identifiers, green/yellow/red statuses, and dim evidence; missing skills/errors render safely. + - Dependencies: Task 4.1 and existing terminal UI conventions. + - TDD: mocked `ui` calls for sections, rows, and status styles. + - Evidence: command test. + - Covers: approved human rendering and AC-13. + +### Phase 5: Documentation and gates + +- [x] **Task 5.1 — Maintain implementation and testing docs** + - Outcome: record changed files, decisions, deviations, security handling, scenarios, and current task state. + - Dependencies: update after every implementation milestone. + - Evidence: feature lint recognizes all five lifecycle documents. + +- [x] **Task 5.2 — Targeted and coverage validation** + - Outcome: all new status tests pass and new/changed logic has meaningful happy/error/security coverage. + - Dependencies: implementation complete. + - Evidence: CLI status test command and CLI coverage command. + +- [x] **Task 5.3 — Repository validation** + - Outcome: six-project build, full test suite, and repository/feature lint pass. + - Dependencies: all code/docs complete. + - Evidence: fresh command output recorded in testing doc. + +- [x] **Task 5.4 — Final lifecycle review** + - Outcome: requirements/design alignment, caller tracing, security, dependency, scope, and rollback review has no blocking findings. + - Dependencies: Task 5.3. + - Evidence: review checklist and clean Git diff. + +- [ ] **Task 5.5 — Publish for review** + - Outcome: fetch/rebase onto latest `origin/main`, rerun relevant gates if rewritten, push branch, and open PR without merging. + - Dependencies: Task 5.4 and clean committed worktree. + - Evidence: remote branch and PR URL. + +## Dependencies and Sequencing + +- Tasks 1.1–1.2 establish the contract and test seams before check implementation. +- Tasks 2.1–3.4 may share pure helpers but are executed sequentially through red/green/refactor cycles to preserve TDD evidence. +- Command/rendering tasks depend on a stable service report. +- Documentation is updated during implementation, not deferred until the final gate. +- Full validation runs only after targeted status tests pass. +- Push and PR creation occur only after final review and a clean committed worktree. +- Optional task tracing is unavailable in CLI 0.55.0 (`unknown command 'task'`), so lifecycle progress remains in this plan and commits. + +## Risks and Mitigations + +- **Secret leakage from provider/config failures:** replace raw failures with fixed messages; test recognizable sentinels across every output path. +- **Status totals drift from nested aggregates:** count explicit leaf checks once through one helper and test exact counts. +- **Real-machine coupling:** inject filesystem/process/network boundaries; never read actual home state in unit tests. +- **Slow or hanging subprocesses:** apply bounded timeouts and make timeout a reportable finding. +- **Channel parser hides corruption:** read raw channel file instead of the repository fallback API. +- **Pi package listing varies by CLI version:** isolate parsing behind one dependency and treat unrecognized output as unknown/failure without raw output. +- **Npm outage:** warn and preserve all other findings. +- **Scope creep into live operations:** no calls to agent listing, sessions, capacity rendering, channel networking/bridges, registry fetch, task/memory, or Git inventory. + +## Validation Matrix + +| Gate | Command | +|---|---| +| Status unit/command tests | `npm test --workspace=ai-devkit -- --run ` | +| CLI coverage | `npm test --workspace=ai-devkit -- --coverage` | +| Six-project build | `npm run build` | +| Full repository tests | `npm test` | +| Base docs lint | `npx ai-devkit@latest lint` | +| Feature docs/worktree lint | `npx ai-devkit@latest lint --feature status-cmd` | +| Diff integrity | `git diff --check` and final review | + +## Progress Summary + +Requirements, design, implementation, testing documentation, coverage, repository build/tests/lint, lifecycle lint, built-command smoke checks, and final review are complete. Review identified and resolved source-tree asset discovery and registry URL credential/query disclosure, with focused regression coverage. The `/tmp` quota blocker was resolved by approved cleanup of stale test artifacts and the previously affected plugin tests pass. The immediate next action is publication without merge. diff --git a/docs/ai/requirements/2026-08-23-feature-status-cmd.md b/docs/ai/requirements/2026-08-23-feature-status-cmd.md new file mode 100644 index 00000000..b398b8a4 --- /dev/null +++ b/docs/ai/requirements/2026-08-23-feature-status-cmd.md @@ -0,0 +1,246 @@ +--- +phase: requirements +title: AI DevKit Status Command +description: Define a read-only setup and readiness report for AI agent managers +feature: status-command +--- + +# Requirements: AI DevKit Status Command + +## Problem Statement + +AI agents using the existing agent-management and agent-orchestration workflows must currently inspect multiple commands, configuration files, provider directories, hooks, and executables before they can determine whether AI DevKit is ready to manage Codex, Pi, and Claude. The checks are fragmented, their failure behavior is inconsistent, and some apparent setup states do not prove that the required integration is healthy. + +The new `ai-devkit status` command must provide one read-only, machine-readable setup and readiness report. Its primary caller is an AI agent-manager deciding whether the local AI DevKit installation, supported agents, hooks, skills, terminal runtime, channels, registries, and current project configuration are usable. Human-readable output may summarize the same report, but JSON is the canonical contract. + +## Goals & Objectives + +### Primary goals + +1. Report the ten approved setup and readiness checks in one command: + 1. Agent executables on `PATH`. + 2. Agent global configuration directories. + 3. AI DevKit built-in skills installed for each agent. + 4. AI DevKit hooks, including Codex session-mapping file health. + 5. Authentication state for each agent. + 6. tmux availability. + 7. Channel connection readiness and channel configuration validity. + 8. Configured project and global skill registries. + 9. Installed AI DevKit version compared with the latest npm version. + 10. Current project configuration presence and validity. +2. Make JSON the canonical representation, with checks nested under their owning agent or subsystem. +3. Give every check a normalized `pass`, `warn`, or `fail` status while retaining concrete evidence such as paths, counts, missing items, and safe error details. +4. Treat missing, invalid, unavailable, or unauthenticated components as reportable findings rather than fatal command errors whenever a structurally useful report can still be produced. +5. Never expose credentials, tokens, session contents, or other secrets. + +### Secondary goals + +- Keep each check independently useful so an agent-manager can make decisions without parsing human prose. +- Distinguish absence, invalid configuration, failed probes, and unknown state instead of collapsing them into a generic unavailable result. +- Preserve specialized commands as the authoritative interfaces for live agents, full capacity, sessions, channels, tasks, memory, and Git details. + +### Non-goals and rejected scope + +The following are explicitly excluded: + +- Skill index existence or freshness. +- Live agent list or agent details. +- Historical session inventory. +- Full capacity or quota detail. +- Live channel bridge or process status. +- Skill registry cache state or refresh. +- Full project or global skill inventory beyond the required AI DevKit built-in set. +- Full Git status, branches, worktrees, or diffs. +- Task and memory database contents. +- Generic host diagnostics such as CPU, RAM, disk, Node/npm versions, or general network health. +- Automatic repair, installation, login, hook rewriting, registry fetching, or any other mutation. + +## User Stories & Use Cases + +1. As an AI agent-manager, I want to know which of Codex, Pi, and Claude have an executable, global configuration, authentication, built-in skills, and required integration hooks so I can choose a usable worker without guessing. +2. As an AI agent-manager, I want missing and degraded components represented in otherwise valid JSON so I can distinguish a setup blocker from a command failure. +3. As an AI agent-manager, I want channel readiness and configuration errors summarized without secret values so I can determine whether remote interaction is locally configured. +4. As a maintainer, I want status evidence tied to authoritative files and existing provider probes so the command does not maintain a second speculative setup model. +5. As a user, I want to know whether the installed AI DevKit CLI is behind the latest npm release without losing the rest of the report when npm is unavailable. + +### Key workflow + +1. The caller runs `ai-devkit status --json` from a project directory. +2. The command evaluates every locally available check independently. +3. It emits one JSON object even when individual agents, files, auth probes, tmux, channel configuration, npm, or project configuration are missing or invalid. +4. The caller uses per-agent and subsystem statuses, evidence, and errors to decide whether to continue, select another agent, or request setup remediation. + +## Functional Requirements + +### FR-01: Agent executables on `PATH` + +- Check `codex`, `pi`, and `claude` independently using executable resolution against the current process `PATH`. +- Report the command name and resolved executable path when found. +- A missing executable must fail that agent's executable check without preventing checks for other agents or subsystems. + +### FR-02: Agent global configuration directories + +- Check existence and readability of `~/.codex`, `~/.pi`, and `~/.claude`. +- Report the expected path and its state beneath the corresponding agent. +- A missing or unreadable directory must be reported and must not abort the command. + +### FR-03: AI DevKit built-in skills per agent + +- Compare the canonical AI DevKit built-in skill set with the applicable global skill directory: + - Codex: `~/.codex/skills//SKILL.md`. + - Pi: `~/.pi/agent/skills//SKILL.md`. + - Claude: `~/.claude/skills//SKILL.md`. +- Report required and present counts plus the exact missing skill names. +- Do not inspect or report skill index existence or freshness. +- Do not expand this check into a full inventory of non-built-in skills. + +### FR-04: AI DevKit hooks and session integration + +#### Codex + +- Check that `~/.codex/hooks/codex-session-mapping.cjs` exists and is readable. +- Check whether the installed script matches the bundled AI DevKit asset. +- Parse `~/.codex/hooks.json` and verify a `SessionStart` command hook exactly registers `node ~/.codex/hooks/codex-session-mapping.cjs`. +- Check `~/.codex/ai-devkit/sessions.json` independently for presence and valid JSON. +- Validate that mapping entries use PID keys and session-file path values. +- Count invalid entries and mappings whose referenced session files no longer exist. +- A missing mapping file is a warning because the hook may not have run yet; malformed mapping data is a failure. +- Never return mapped session contents. + +#### Claude + +- Check that `~/.claude/hooks/claude-prompt-hook.js` exists and is readable. +- Check whether the installed script matches the bundled AI DevKit asset. +- Parse `~/.claude/settings.json` and verify a `PreToolUse` command hook exactly registers `node ~/.claude/hooks/claude-prompt-hook.js`. + +#### Pi + +- Check whether `@ai-devkit/pi-session-tracker` is registered with Pi. +- Check `~/.pi/agent/sessions.json`, when present, for valid JSON containing PID-to-session-path entries. +- A missing sessions registry is a warning because no Pi session may have started yet; malformed registry data is a failure. +- Never return tracked session contents. + +### FR-05: Authentication state per agent + +- Report `authenticated`, `unauthenticated`, or `unknown` separately from the normalized check status. +- Codex must reuse the existing read-only Codex authentication probe backed by `CODEX_HOME/auth.json` or `~/.codex/auth.json`. +- Claude must use the provider-native, read-only `claude auth status --json` probe. +- Pi may inspect only the presence and structural validity of `~/.pi/agent/auth.json`; if that evidence cannot prove current credential validity, report `unknown`, not `authenticated`. +- Authentication checks must not invoke a model turn, refresh credentials, log in, or emit credential material. +- Full provider capacity and quota data remains excluded. + +### FR-06: tmux availability + +- Resolve `tmux` on `PATH` and run `tmux -V` as a read-only usability probe. +- Report the resolved path, availability, and returned version. +- The check does not require a running tmux server or an existing tmux session. + +### FR-07: Channel readiness and configuration validity + +- Read `~/.ai-devkit/channels.json` without mutating it. +- Report file presence, JSON validity, root schema validity, per-entry schema validity, and safe validation errors. +- For each configured channel, report its name, type, enabled state, credential presence, authorization state where applicable, local readiness, and normalized status. +- Telegram is locally ready only when enabled with a non-empty bot token, non-empty bot username, and an authorized chat ID. +- Slack is locally ready only when enabled with an `xapp-` app token, an `xoxb-` bot token, required workspace and bot identity fields, `socket-mode` transport, and `dm` audience. +- Credential values must never appear in output or errors. +- This is a local configuration-readiness check. It must not probe provider networks or report live channel bridge/process status. + +### FR-08: Configured registries + +- Report normalized registry identifiers and URLs from both sources: + - Project: `/.ai-devkit.json` `registries`. + - Global: `~/.ai-devkit/.ai-devkit.json` `registries`. +- Preserve project and global provenance. +- Do not clone, fetch, refresh, or inspect registry caches. + +### FR-09: AI DevKit version versus npm latest + +- Read the installed version from the running AI DevKit CLI package metadata. +- Query the latest published version using the npm registry equivalent of `npm view ai-devkit version`. +- Report installed version, latest version, source, and whether an update is available. +- If npm is unavailable or returns invalid data, report latest version and update availability as unknown with a warning; the rest of the status report must remain usable. +- Do not add general Node or npm version diagnostics. + +### FR-10: Project configuration presence and validity + +- Resolve `/.ai-devkit.json`. +- Report file presence, JSON parse validity, recognized structure, configured version, and configured environment codes. +- Validate environment codes against AI DevKit's canonical environment definitions. +- Return concrete, safe validation errors. +- Missing or invalid project configuration must be reportable without aborting unrelated checks. + +## Output Contract + +### Canonical JSON + +- `ai-devkit status --json` is the canonical machine-readable interface. +- The top-level object must include `generatedAt`, `overall`, `aiDevkit`, `project`, `agents`, `tmux`, `registries`, `channels`, and aggregate `checks` counts. +- `agents` must contain stable per-agent objects keyed by `codex`, `pi`, and `claude`. +- Agent-specific executable, global configuration, authentication, built-in-skill, and hook results must remain nested under that agent. +- Every leaf check and every meaningful aggregate must use `pass`, `warn`, or `fail`. +- Arrays must remain arrays when empty, and unavailable scalar values must be represented explicitly as `null` rather than omitted when their absence is meaningful. +- Findings must include safe evidence such as paths, counts, missing item names, and redacted errors where needed. + +### Overall status + +- `fail` means at least one required check failed. +- `warn` means no check failed and at least one check warned. +- `pass` means all evaluated checks passed. +- Aggregate counts must match the emitted leaf checks. + +### Failure behavior + +- Missing files, missing executables, invalid local configuration, unauthenticated agents, unavailable npm, and failed provider probes are findings, not reasons to suppress the report. +- The command may exit non-zero only when it cannot produce a structurally valid and useful report, such as an internal serialization failure. +- One failed probe must not prevent independent probes from running. + +### Secret handling + +- Output must never contain auth tokens, API keys, channel tokens, refresh tokens, credential file contents, session contents, or raw provider failures that may embed secrets. +- Paths and errors must be sanitized before emission. +- Human-readable output, JSON output, debug output, and thrown errors are all subject to the same no-secrets rule. + +## Acceptance Criteria + +- **AC-01 / FR-01:** With any combination of `codex`, `pi`, and `claude` present or absent on `PATH`, JSON reports each command independently with its resolved path or a failed missing-executable finding. +- **AC-02 / FR-02:** JSON reports presence and readability for `~/.codex`, `~/.pi`, and `~/.claude` beneath the correct agent, and missing directories do not abort the report. +- **AC-03 / FR-03:** For each agent, JSON compares the canonical built-in set against the correct global skills directory and reports counts and exact missing names without consulting a skill index or listing unrelated skills. +- **AC-04 / FR-04:** Codex script and `SessionStart` registration, Claude script and `PreToolUse` registration, and Pi tracker registration are verified independently; Codex/Pi mapping registries distinguish missing, malformed, invalid, and stale-entry states without exposing session contents. +- **AC-05 / FR-05:** Each agent reports `authenticated`, `unauthenticated`, or `unknown` from the approved evidence source, and tests prove credentials and raw auth responses cannot appear in any output. +- **AC-06 / FR-06:** tmux reports its resolved path and version when `tmux -V` succeeds, and reports an isolated failure when it is absent or unusable without requiring a running server. +- **AC-07 / FR-07:** Valid and invalid Telegram and Slack configurations produce deterministic local readiness results; malformed channel JSON and schemas are reported safely; no network or bridge liveness probe runs. +- **AC-08 / FR-08:** Project and global registries are normalized and returned with provenance without any registry cache access or network refresh. +- **AC-09 / FR-09:** Installed and latest npm versions produce a correct update flag; npm failure produces `null` latest/update values and a warning while all other checks remain present. +- **AC-10 / FR-10:** Missing, valid, malformed, and structurally invalid `.ai-devkit.json` cases produce explicit project-config findings while unrelated checks still run. +- **AC-11 / Output contract:** JSON uses stable per-agent nesting, normalized `pass`/`warn`/`fail` values, explicit empty arrays/nulls, an overall status derived from leaf checks, and matching aggregate counts. +- **AC-12 / Failure behavior:** A fixture with multiple simultaneous setup failures still returns one structurally valid report containing every independently evaluable check. +- **AC-13 / Secret handling:** Automated tests place recognizable secrets in every credential/config source and provider error path and verify none appear in JSON, human-readable, debug, or error output. +- **AC-14 / Scope guard:** Tests or review confirm the command does not enumerate live agents or historical sessions, return capacity details, inspect skill-index/cache freshness, perform live channel checks, expose task/memory/Git inventories, collect generic host diagnostics, or mutate local/external state. + +## Constraints & Assumptions + +### Technical constraints + +- The command is read-only and must use existing constants, path definitions, parsers, setup assets, and provider probes where they are authoritative. +- Probes must be isolated so one timeout, malformed file, absent executable, or unavailable service cannot discard other results. +- File comparison for bundled hooks must be deterministic and must not execute hook code. +- Network access is limited to the npm latest-version lookup; channel readiness is local-only, and authentication probes must follow their approved read-only boundaries. +- JSON field names and status semantics form the agent-manager-facing contract and require tests. + +### Assumptions + +- The globally managed setup scope for this command is Codex, Pi, and Claude. +- The canonical built-in skill list remains owned by AI DevKit and is not duplicated in the status implementation. +- File presence alone does not prove authentication unless the approved provider probe establishes it. +- A session mapping registry may legitimately be absent before the corresponding agent has produced a session. +- The existing human CLI may render a concise projection of the same model, but JSON remains authoritative. + +### Rollout + +- Introduce `ai-devkit status` without changing existing specialized commands. +- Do not automatically invoke `status` from agent-management or orchestration skills in this requirements phase; integration changes require separate approved scope. +- Preserve existing setup behavior and files; the new command only observes them. + +## Questions & Open Items + +- None. The command scope, evidence boundaries, JSON status model, failure behavior, secret-handling rule, and rejected scope are approved for design. diff --git a/docs/ai/testing/2026-08-23-feature-status-cmd.md b/docs/ai/testing/2026-08-23-feature-status-cmd.md new file mode 100644 index 00000000..cd8e914a --- /dev/null +++ b/docs/ai/testing/2026-08-23-feature-status-cmd.md @@ -0,0 +1,91 @@ +--- +phase: testing +title: AI DevKit Status Command Testing +description: Test strategy and fresh validation evidence for setup readiness reporting +feature: status-command +--- + +# Testing: AI DevKit Status Command + +## Test Coverage Goals + +- Cover every approved functional requirement FR-01 through FR-10 and acceptance criterion AC-01 through AC-14. +- Exercise happy, missing, malformed, unavailable, stale, and multi-failure paths through injected dependencies rather than real user credentials or provider networks. +- Prove JSON contract stability, per-agent nesting, status/count aggregation, nonfatal findings, and secret suppression. +- Run command-level tests, CLI package coverage, the six-project build, full repository tests, code lint, and lifecycle docs lint. + +## Unit Tests + +### Canonical status service + +- [x] Complete fixture reports executable paths, global directories, built-in skill completeness, hooks, mappings, auth, tmux, channels, registries, versions, and project configuration. (AC-01–AC-10) +- [x] Codex, Pi, and Claude remain independently nested and present. (AC-11) +- [x] Multiple filesystem, command, auth, and npm failures still produce a complete report. (AC-12) +- [x] Raw filesystem and subprocess secret sentinels do not appear in serialized JSON. (AC-13) +- [x] Malformed Codex mapping and channel JSON is reported without returning source contents. (AC-04, AC-07, AC-13) +- [x] Structurally incomplete channel entries invalidate schema/readiness. (AC-07) +- [x] Executable access uses an explicit permission mode and missing paths remain isolated findings. (AC-01) +- [x] Built-in skills are compared only with the canonical set; no skill index/cache is injected or read. (AC-03, AC-14) +- [x] Pi credential-file presence yields unknown/warn rather than an unverified authenticated claim. (AC-05) + +### Command and rendering + +- [x] JSON rendering emits `JSON.stringify(report, null, 2)` exactly. (AC-11, AC-13) +- [x] Human rendering uses shared `ui.table` with agent status rows. (approved UX) +- [x] Commander registers `status --json`, calls the injected report reader once, and renders JSON. (command contract) + +## Integration Tests + +- [x] Six-project TypeScript/SWC build resolves the new CLI imports and bundled asset paths. +- [x] Built `node packages/cli/dist/cli.js status --json` emits parseable JSON with `codex`, `pi`, and `claude` and a normalized overall status. +- [x] Full CLI suite validates adjacent plugin, setup, capacity, channel, skill, and agent commands with the new top-level registration. +- [x] Full repository suite validates all six packages together. +- [x] Feature lint recognizes requirements, design, planning, implementation, testing, branch, and worktree. + +## Security and Scope Tests + +- [x] Recognizable secrets in channel tokens, malformed source content, filesystem errors, and subprocess errors are absent from the report. +- [x] Registry credentials, query strings, and fragments are removed before configured registry URLs enter either renderer. +- [x] Mapping validators expose only counts/status and never mapping keys or session contents. +- [x] Channel readiness is derived locally; no Telegram/Slack client exists in status dependencies. +- [x] No live agent/session, task, memory, Git, registry-fetch, skill-index, channel-bridge, or full-capacity output dependency is present. +- [x] Hook scripts are compared as text and never executed. +- [x] Expected failures use fixed safe messages. + +## Test Fixtures and Boundaries + +- In-memory path-to-content fixture for project/global config, channels, hooks, auth structure, skills, and mappings. +- Injected access boundary for readable/executable/missing paths. +- Injected subprocess boundary for tmux, Pi tracker listing, Claude auth, and npm latest version. +- Injected Codex auth boundary returning only `true`, `false`, or `null`. +- Fixed clock and installed version for deterministic output. +- Dedicated `~/.ai-devkit/status-command-tmp` test temporary directory used while shared `/tmp` quota was exhausted. + +## Fresh Validation Evidence + +| Gate | Result | Evidence | +|---|---|---| +| Targeted status tests | Passed | 2 files, 7 tests | +| Isolated plugin-loader regression check | Passed | 1 file, 9 tests after stale temp cleanup | +| CLI coverage | Passed | 88 files, 1,057 tests; 77.47% statements overall | +| Status service coverage | Passed | 91.24% statements, 72.63% branches, 88.67% functions, 92.14% lines | +| Six-project build | Passed | `nx run-many -t build`, 6 projects | +| Built JSON smoke test | Passed | Parseable report; three required agent keys and normalized overall status | +| Full repository tests | Passed | 6 targets, 1,984 tests: channel 115, memory 110, task manager 112, agent manager 568, memory dashboard 22, CLI 1,057 | +| Code lint | Passed | 6 targets, zero errors; four pre-existing unused-catch warnings outside changed files | +| Base/feature docs lint | Passed | All five base templates, all five feature docs, branch, and worktree recognized | + +The targeted status tests, six-project build, full 1,984-test repository suite, code lint, and both lifecycle lint commands were rerun after the final review fixes. Results above describe the final code commit. + +## Environment Issue Resolved + +The first full CLI run produced two plugin-loader failures with `Disk quota exceeded`. The affected tests hard-code `/tmp`; a direct 4 KB write there reproduced the same error. Thirty-two current-user `/tmp/tmp-*` test directories older than one day were removed after approval. A direct write probe and the isolated 9-test plugin suite then passed. No repository, home, recent temporary, or active Codex mount path was removed. + +## Manual Testing + +- [x] Execute built JSON command and parse the result programmatically. +- [x] Execute built human command and confirm non-empty section/table output from the canonical report. + +## Remaining Gate + +All testing and review gates pass. Proceed to fetch/rebase, push, and PR creation without merging. diff --git a/packages/cli/src/__tests__/commands/status.test.ts b/packages/cli/src/__tests__/commands/status.test.ts new file mode 100644 index 00000000..2cdca4fd --- /dev/null +++ b/packages/cli/src/__tests__/commands/status.test.ts @@ -0,0 +1,67 @@ +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerStatusCommand } from '../../commands/status.js'; +import { renderStatusReport } from '../../commands/status/render.js'; +import { ui } from '../../util/terminal-ui.js'; +import type { StatusReport } from '../../services/status/status.service.js'; + +vi.mock('../../util/terminal-ui.js', () => ({ + ui: { text: vi.fn(), table: vi.fn(), warning: vi.fn(), breakline: vi.fn() }, +})); + +const base = { status: 'pass' as const, errors: [] as string[] }; +function agent(status: 'pass' | 'warn' | 'fail') { + return { + status, + executable: { ...base, command: 'agent', path: '/bin/agent' }, + globalConfig: { ...base, path: '~/.agent', present: true, readable: true }, + auth: { ...base, state: 'authenticated', source: 'test' }, + builtInSkills: { ...base, path: '~/.agent/skills', required: 20, present: 20, missing: [] }, + hooks: { status: 'pass' }, + }; +} +const report = { + generatedAt: '2026-08-23T00:00:00.000Z', + overall: 'warn', + aiDevkit: { ...base, installedVersion: '0.55.0', latestVersion: '0.56.0', updateAvailable: true, latestVersionSource: 'npm' }, + project: { cwd: '/repo', config: { ...base, path: '/repo/.ai-devkit.json', present: true, valid: true, version: '0.55.0', environments: ['codex'] } }, + agents: { + codex: agent('pass'), pi: agent('warn'), claude: agent('fail'), + }, + tmux: { ...base, path: '/bin/tmux', available: true, version: '3.4' }, + registries: { project: { ...base, source: '/repo/.ai-devkit.json', configured: {} }, global: { ...base, source: '~/.ai-devkit/.ai-devkit.json', configured: {} }, status: 'pass' }, + channels: { config: { ...base, path: '~/.ai-devkit/channels.json', present: true, validJson: true, validSchema: true }, connections: [], readyCount: 0, status: 'pass' }, + checks: { passed: 20, warnings: 1, failed: 1 }, +} as unknown as StatusReport; + +describe('status command', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders canonical JSON exactly', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + renderStatusReport(report, { json: true }); + expect(log).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + expect(ui.table).not.toHaveBeenCalled(); + log.mockRestore(); + }); + + it('renders human status with shared terminal tables', () => { + renderStatusReport(report); + expect(ui.text).toHaveBeenCalledWith('AI DevKit Status:', { breakline: true }); + expect(ui.table).toHaveBeenCalledWith(expect.objectContaining({ + headers: ['Agent', 'Status'], + rows: [['codex', 'pass'], ['pi', 'warn'], ['claude', 'fail']], + })); + }); + + it('registers the top-level status command and passes json intent', async () => { + const readReport = vi.fn(async () => report); + const program = new Command(); + registerStatusCommand(program, readReport); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await program.parseAsync(['node', 'test', 'status', '--json']); + expect(readReport).toHaveBeenCalledOnce(); + expect(log).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + log.mockRestore(); + }); +}); diff --git a/packages/cli/src/__tests__/services/status/status.service.test.ts b/packages/cli/src/__tests__/services/status/status.service.test.ts new file mode 100644 index 00000000..478636aa --- /dev/null +++ b/packages/cli/src/__tests__/services/status/status.service.test.ts @@ -0,0 +1,165 @@ +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { getStatusReport, type StatusServiceOptions } from '../../../services/status/status.service.js'; + +type Files = Record; + +function fixture(overrides: Partial = {}) { + const homeDir = '/home/test'; + const cwd = '/repo'; + const assetRoot = '/assets'; + const builtIns = [ + 'agent-communication', 'agent-management', 'dev-commit', 'dev-lifecycle', 'dev-worktree', + 'dev-requirements', 'dev-design', 'dev-planning', 'dev-implementation', 'dev-testing', + 'dev-review', 'dev-pr', 'structured-debug', 'document-code', 'memory', 'task', + 'simplify-implementation', 'brainstorm', 'verify', 'tdd', + ]; + const files: Files = { + [path.join(cwd, '.ai-devkit.json')]: JSON.stringify({ + version: '0.55.0', environments: ['codex', 'pi', 'claude'], phases: [], createdAt: 'now', + registries: { + project: 'https://example.test/project.git', + private: 'https://user:registry-secret@example.test/private.git?token=query-secret', + }, + }), + [path.join(homeDir, '.ai-devkit', '.ai-devkit.json')]: JSON.stringify({ + registries: { global: 'https://example.test/global.git' }, + }), + [path.join(homeDir, '.ai-devkit', 'channels.json')]: JSON.stringify({ channels: { + telegram: { type: 'telegram', enabled: true, createdAt: 'now', config: { + botToken: 'telegram-secret', botUsername: 'safe-bot', authorizedChatId: 42, + } }, + slack: { type: 'slack', enabled: true, createdAt: 'now', config: { + appToken: 'xapp-secret', botToken: 'xoxb-secret', botUserId: 'B1', workspaceId: 'W1', + transport: 'socket-mode', audience: 'dm', + } }, + } }), + [path.join(homeDir, '.codex', 'hooks', 'codex-session-mapping.cjs')]: 'codex-hook', + [path.join(assetRoot, 'codex', 'codex-session-mapping.cjs')]: 'codex-hook', + [path.join(homeDir, '.codex', 'hooks.json')]: JSON.stringify({ hooks: { SessionStart: [{ hooks: [ + { type: 'command', command: 'node ~/.codex/hooks/codex-session-mapping.cjs' }, + ] }] } }), + [path.join(homeDir, '.codex', 'ai-devkit', 'sessions.json')]: JSON.stringify({ '123': '/sessions/codex.jsonl' }), + '/sessions/codex.jsonl': '', + [path.join(homeDir, '.claude', 'hooks', 'claude-prompt-hook.js')]: 'claude-hook', + [path.join(assetRoot, 'claude', 'claude-prompt-hook.js')]: 'claude-hook', + [path.join(homeDir, '.claude', 'settings.json')]: JSON.stringify({ hooks: { PreToolUse: [{ hooks: [ + { type: 'command', command: 'node ~/.claude/hooks/claude-prompt-hook.js' }, + ] }] } }), + [path.join(homeDir, '.pi', 'agent', 'sessions.json')]: JSON.stringify({ '456': '/sessions/pi.jsonl' }), + '/sessions/pi.jsonl': '', + [path.join(homeDir, '.pi', 'agent', 'auth.json')]: JSON.stringify({ provider: 'anthropic' }), + }; + for (const directory of ['.codex', '.pi', '.claude']) files[path.join(homeDir, directory)] = ''; + for (const [agent, skillRoot] of [ + ['codex', path.join(homeDir, '.codex', 'skills')], + ['pi', path.join(homeDir, '.pi', 'agent', 'skills')], + ['claude', path.join(homeDir, '.claude', 'skills')], + ] as const) { + void agent; + for (const skill of builtIns) files[path.join(skillRoot, skill, 'SKILL.md')] = '# skill'; + } + const executablePaths: Record = { + codex: '/bin/codex', pi: '/bin/pi', claude: '/bin/claude', tmux: '/bin/tmux', + }; + const options: StatusServiceOptions = { + cwd, + homeDir, + path: '/bin', + assetRoot, + installedVersion: '0.55.0', + now: () => new Date('2026-08-23T00:00:00.000Z'), + readFile: async (target) => { + if (!(target in files)) throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + return files[target]; + }, + access: async (target, mode) => { + expect(mode).toBeTypeOf('number'); + if (Object.values(executablePaths).includes(target) || target in files) return; + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + }, + runCommand: vi.fn(async (command, args) => { + if (command === 'tmux') return { stdout: 'tmux 3.4\n', stderr: '' }; + if (command === 'pi') return { stdout: '@ai-devkit/pi-session-tracker\n', stderr: '' }; + if (command === 'claude') return { stdout: JSON.stringify({ loggedIn: true }), stderr: '' }; + if (command === 'npm') return { stdout: '0.56.0\n', stderr: '' }; + throw new Error(`unexpected command ${command} ${args.join(' ')}`); + }), + codexAuth: async () => true, + ...overrides, + }; + return { options, files }; +} + +describe('getStatusReport', () => { + it('builds the canonical per-agent readiness report from verifiable sources', async () => { + const { options } = fixture(); + const report = await getStatusReport(options); + + expect(report.generatedAt).toBe('2026-08-23T00:00:00.000Z'); + expect(report.agents.codex.executable.path).toBe('/bin/codex'); + expect(report.agents.codex.auth.state).toBe('authenticated'); + expect(report.agents.codex.builtInSkills.missing).toEqual([]); + expect(report.agents.codex.hooks.mappingFile).toMatchObject({ valid: true, staleEntries: 0 }); + expect(report.agents.pi.hooks.sessionTracker).toMatchObject({ installed: true, registryValid: true }); + expect(report.agents.pi.auth).toMatchObject({ state: 'unknown', status: 'warn' }); + expect(report.agents.claude.hooks.registration).toMatchObject({ present: true, valid: true }); + expect(report.tmux).toMatchObject({ path: '/bin/tmux', available: true, version: '3.4' }); + expect(report.registries.project.configured).toMatchObject({ project: 'https://example.test/project.git' }); + expect(report.registries.global.configured).toEqual({ global: 'https://example.test/global.git' }); + expect(report.aiDevkit).toMatchObject({ installedVersion: '0.55.0', latestVersion: '0.56.0', updateAvailable: true }); + expect(report.project.config).toMatchObject({ present: true, valid: true, environments: ['codex', 'pi', 'claude'] }); + expect(report.channels.connections).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'telegram', ready: true }), + expect.objectContaining({ name: 'slack', ready: true }), + ])); + expect(report.checks.warnings).toBeGreaterThan(0); + expect(report.registries.project.configured.private).toBe('https://example.test/private.git'); + expect(JSON.stringify(report)).not.toContain('registry-secret'); + expect(JSON.stringify(report)).not.toContain('query-secret'); + }); + + it('returns independent findings when files, commands, auth, and npm are unavailable', async () => { + const { options } = fixture({ + access: async () => { throw new Error('SECRET access failure'); }, + readFile: async () => { throw new Error('SECRET file failure'); }, + runCommand: async () => { throw new Error('SECRET command failure'); }, + codexAuth: async () => null, + }); + const report = await getStatusReport(options); + const serialized = JSON.stringify(report); + + expect(report.agents.codex.executable.status).toBe('fail'); + expect(report.agents.claude.auth.state).toBe('unknown'); + expect(report.project.config.present).toBe(false); + expect(report.aiDevkit.latestVersion).toBeNull(); + expect(report.overall).toBe('fail'); + expect(serialized).not.toContain('SECRET'); + expect(report.agents.codex).toBeDefined(); + expect(report.agents.pi).toBeDefined(); + expect(report.agents.claude).toBeDefined(); + }); + + it('reports malformed mappings and channel config without exposing their contents', async () => { + const { options, files } = fixture(); + files[path.join(options.homeDir!, '.codex', 'ai-devkit', 'sessions.json')] = '{token-secret'; + files[path.join(options.homeDir!, '.ai-devkit', 'channels.json')] = '{channel-secret'; + + const report = await getStatusReport(options); + const serialized = JSON.stringify(report); + expect(report.agents.codex.hooks.mappingFile.status).toBe('fail'); + expect(report.channels.config).toMatchObject({ present: true, validJson: false, validSchema: false, status: 'fail' }); + expect(serialized).not.toContain('token-secret'); + expect(serialized).not.toContain('channel-secret'); + }); + + it('marks the channel schema invalid when an entry is structurally incomplete', async () => { + const { options, files } = fixture(); + files[path.join(options.homeDir!, '.ai-devkit', 'channels.json')] = JSON.stringify({ + channels: { broken: { type: 'slack', enabled: true, config: { appToken: 'xapp-secret' } } }, + }); + const report = await getStatusReport(options); + expect(report.channels.config).toMatchObject({ validJson: true, validSchema: false, status: 'fail' }); + expect(report.channels.connections[0]).toMatchObject({ ready: false, status: 'fail' }); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8e2045cb..fae9ae15 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,6 +13,7 @@ import { registerDocsCommand } from './commands/docs.js'; import { registerPluginCommand } from './commands/plugin.js'; import { registerSetupCommand } from './commands/setup.js'; import { registerCapacityCommand } from './commands/capacity.js'; +import { registerStatusCommand } from './commands/status.js'; import { registerConfiguredPluginCommands } from './services/plugin/plugin-loader.service.js'; import { createAiDevkitRuntime } from './services/plugin/runtime.js'; import { handleCliError } from './util/errors.js'; @@ -66,6 +67,7 @@ registerDocsCommand(program); registerPluginCommand(program); registerSetupCommand(program); registerCapacityCommand(program); +registerStatusCommand(program); await registerConfiguredPluginCommands(program, createAiDevkitRuntime()); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts new file mode 100644 index 00000000..d0037b14 --- /dev/null +++ b/packages/cli/src/commands/status.ts @@ -0,0 +1,19 @@ +import type { Command } from 'commander'; +import { getStatusReport, type StatusReport } from '../services/status/status.service.js'; +import { withErrorHandler } from '../util/errors.js'; +import { renderStatusReport } from './status/render.js'; + +type ReportReader = () => Promise; + +export function registerStatusCommand( + program: Command, + readReport: ReportReader = getStatusReport, +): void { + program + .command('status') + .description('Report AI DevKit setup and readiness') + .option('-j, --json', 'Output as JSON') + .action(withErrorHandler('report status', async (options: { json?: boolean }) => { + renderStatusReport(await readReport(), options); + })); +} diff --git a/packages/cli/src/commands/status/render.ts b/packages/cli/src/commands/status/render.ts new file mode 100644 index 00000000..2ae737e1 --- /dev/null +++ b/packages/cli/src/commands/status/render.ts @@ -0,0 +1,66 @@ +import chalk from 'chalk'; +import { ui } from '../../util/terminal-ui.js'; +import type { CheckStatus, StatusReport } from '../../services/status/status.service.js'; + +function statusStyle(text: string): string { + return text === 'pass' ? chalk.green(text) : text === 'warn' ? chalk.yellow(text) : chalk.red(text); +} + +function yesNo(value: boolean): string { + return value ? 'yes' : 'no'; +} + +export function renderStatusReport(report: StatusReport, options: { json?: boolean } = {}): void { + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + ui.text('AI DevKit Status:', { breakline: true }); + ui.table({ + headers: ['Scope', 'Status', 'Details'], + rows: [ + ['overall', report.overall, `${report.checks.passed} pass · ${report.checks.warnings} warn · ${report.checks.failed} fail`], + ['ai-devkit', report.aiDevkit.status, report.aiDevkit.latestVersion + ? `${report.aiDevkit.installedVersion} (latest ${report.aiDevkit.latestVersion})` + : `${report.aiDevkit.installedVersion} (latest unknown)`], + ['project', report.project.config.status, report.project.config.path], + ['tmux', report.tmux.status, report.tmux.available ? `${report.tmux.path} · ${report.tmux.version ?? 'unknown'}` : 'unavailable'], + ['registries', report.registries.status, + `${Object.keys(report.registries.project.configured).length} project · ${Object.keys(report.registries.global.configured).length} global`], + ['channels', report.channels.status, `${report.channels.readyCount}/${report.channels.connections.length} ready`], + ], + maxWidth: process.stdout.columns ?? 120, + columnStyles: [chalk.cyan, statusStyle, chalk.dim], + }); + + ui.text('Agents:', { breakline: true }); + ui.table({ + headers: ['Agent', 'Status'], + rows: (['codex', 'pi', 'claude'] as const).map(agent => [agent, report.agents[agent].status]), + maxWidth: process.stdout.columns ?? 120, + columnStyles: [chalk.cyan, statusStyle], + }); + + const details: Array<[string, CheckStatus, string]> = []; + for (const agent of ['codex', 'pi', 'claude'] as const) { + const item = report.agents[agent]; + details.push( + [`${agent}: executable`, item.executable.status, item.executable.path ?? 'not found'], + [`${agent}: config`, item.globalConfig.status, item.globalConfig.path], + [`${agent}: auth`, item.auth.status, item.auth.state], + [`${agent}: skills`, item.builtInSkills.status, `${item.builtInSkills.present}/${item.builtInSkills.required}`], + [`${agent}: hooks`, item.hooks.status, yesNo(item.hooks.status === 'pass')], + ); + } + ui.table({ + headers: ['Check', 'Status', 'Evidence'], rows: details, + maxWidth: process.stdout.columns ?? 120, + columnStyles: [chalk.cyan, statusStyle, chalk.dim], + }); + + for (const agent of ['codex', 'pi', 'claude'] as const) { + const missing = report.agents[agent].builtInSkills.missing; + if (missing.length) ui.warning(`${agent} missing built-in skills: ${missing.join(', ')}`); + } +} diff --git a/packages/cli/src/services/status/status.service.ts b/packages/cli/src/services/status/status.service.ts new file mode 100644 index 00000000..8c062781 --- /dev/null +++ b/packages/cli/src/services/status/status.service.ts @@ -0,0 +1,663 @@ +import { constants, existsSync } from 'node:fs'; +import { access as fsAccess, readFile as fsReadFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { dirname, delimiter, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { AGENTS, getCodexCapacityReport } from '@ai-devkit/agent-manager'; +import { BUILTIN_SKILL_NAMES } from '../../constants.js'; +import { filterStringRecord } from '../../util/config.js'; +import { getGlobalSkillPath, isValidEnvironmentCode } from '../../util/env.js'; +import packageJson from '../../../package.json' with { type: 'json' }; + +const execFileAsync = promisify(execFile); + +export type CheckStatus = 'pass' | 'warn' | 'fail'; +export type AuthState = 'authenticated' | 'unauthenticated' | 'unknown'; +type AgentKey = 'codex' | 'pi' | 'claude'; +type CommandResult = { stdout: string; stderr: string }; +type ReadFile = (target: string) => Promise; +type Access = (target: string, mode?: number) => Promise; +type RunCommand = (command: string, args: string[]) => Promise; + +interface CheckBase { status: CheckStatus; errors: string[] } +interface ExecutableCheck extends CheckBase { command: string; path: string | null } +interface DirectoryCheck extends CheckBase { path: string; present: boolean; readable: boolean } +interface AuthCheck extends CheckBase { state: AuthState; source: string } +interface SkillsCheck extends CheckBase { + path: string; + required: number; + present: number; + missing: string[]; +} +interface ScriptCheck extends CheckBase { + path: string; + present: boolean; + readable: boolean; + matchesBundledAsset: boolean; +} +interface RegistrationCheck extends CheckBase { + path: string; + event: string; + command: string; + present: boolean; + valid: boolean; +} +interface MappingCheck extends CheckBase { + path: string; + present: boolean; + valid: boolean; + invalidEntries: number; + staleEntries: number; +} +interface TrackerCheck extends CheckBase { + package: string; + installed: boolean; + registryPath: string; + registryValid: boolean; + invalidEntries: number; + staleEntries: number; +} +interface HookGroupBase { status: CheckStatus } +interface CodexHooks extends HookGroupBase { + sessionMappingScript: ScriptCheck; + registration: RegistrationCheck; + mappingFile: MappingCheck; +} +interface ClaudeHooks extends HookGroupBase { + promptScript: ScriptCheck; + registration: RegistrationCheck; +} +interface PiHooks extends HookGroupBase { sessionTracker: TrackerCheck } +interface AgentCheck { + executable: ExecutableCheck; + globalConfig: DirectoryCheck; + auth: AuthCheck; + builtInSkills: SkillsCheck; + hooks: H; + status: CheckStatus; +} +interface ProjectConfigCheck extends CheckBase { + path: string; + present: boolean; + valid: boolean; + version: string | null; + environments: string[]; +} +interface RegistryScopeCheck extends CheckBase { + source: string; + configured: Record; +} +interface RegistriesCheck { + project: RegistryScopeCheck; + global: RegistryScopeCheck; + status: CheckStatus; +} +interface VersionCheck extends CheckBase { + installedVersion: string; + latestVersion: string | null; + updateAvailable: boolean | null; + latestVersionSource: 'npm'; +} +interface TmuxCheck extends CheckBase { + path: string | null; + available: boolean; + version: string | null; +} +interface ChannelConnection extends CheckBase { + name: string; + type: string; + enabled: boolean; + credentialsPresent: boolean; + authorized: boolean | null; + ready: boolean; +} +interface ChannelConfigCheck extends CheckBase { + path: string; + present: boolean; + validJson: boolean; + validSchema: boolean; +} +interface ChannelsCheck { + config: ChannelConfigCheck; + connections: ChannelConnection[]; + readyCount: number; + status: CheckStatus; +} + +export interface StatusReport { + generatedAt: string; + overall: CheckStatus; + aiDevkit: VersionCheck; + project: { cwd: string; config: ProjectConfigCheck }; + agents: { + codex: AgentCheck; + pi: AgentCheck; + claude: AgentCheck; + }; + tmux: TmuxCheck; + registries: RegistriesCheck; + channels: ChannelsCheck; + checks: { passed: number; warnings: number; failed: number }; +} + +export interface StatusServiceOptions { + cwd?: string; + homeDir?: string; + path?: string; + assetRoot?: string; + installedVersion?: string; + now?: () => Date; + readFile?: ReadFile; + access?: Access; + runCommand?: RunCommand; + codexAuth?: () => Promise; +} + +type Runtime = Required; + +const AGENT_META: Record = { + codex: { dotDir: '.codex', skillEnv: 'codex' }, + pi: { dotDir: '.pi', skillEnv: 'pi' }, + claude: { dotDir: '.claude', skillEnv: 'claude' }, +}; + +function statusRank(status: CheckStatus): number { + return status === 'fail' ? 2 : status === 'warn' ? 1 : 0; +} + +export function worstStatus(statuses: CheckStatus[]): CheckStatus { + return statuses.reduce((worst, current) => + statusRank(current) > statusRank(worst) ? current : worst, 'pass'); +} + +function displayHome(target: string, homeDir: string): string { + return target === homeDir ? '~' : target.startsWith(`${homeDir}/`) ? `~${target.slice(homeDir.length)}` : target; +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record : null; +} + +async function defaultRunCommand(command: string, args: string[]): Promise { + const result = await execFileAsync(command, args, { + encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024, + }); + return { stdout: result.stdout, stderr: result.stderr }; +} + +function resolveDefaultAssetRoot(): string { + const serviceDir = dirname(fileURLToPath(import.meta.url)); + const candidates = [resolve(serviceDir, '../../assets'), resolve(serviceDir, '../../../assets')]; + return candidates.find(candidate => existsSync(candidate)) ?? candidates[0]; +} + +function runtime(options: StatusServiceOptions): Runtime { + return { + cwd: resolve(options.cwd ?? process.cwd()), + homeDir: options.homeDir ?? process.env.HOME ?? '', + path: options.path ?? process.env.PATH ?? '', + assetRoot: options.assetRoot ?? resolveDefaultAssetRoot(), + installedVersion: options.installedVersion ?? packageJson.version, + now: options.now ?? (() => new Date()), + readFile: options.readFile ?? (target => fsReadFile(target, 'utf8')), + access: options.access ?? ((target, mode = constants.R_OK) => fsAccess(target, mode)), + runCommand: options.runCommand ?? defaultRunCommand, + codexAuth: options.codexAuth ?? (async () => (await getCodexCapacityReport()).authenticated), + }; +} + +async function accessible(target: string, rt: Runtime, mode = constants.R_OK): Promise { + try { await rt.access(target, mode); return true; } catch { return false; } +} + +async function resolveExecutable(command: string, rt: Runtime): Promise { + for (const directory of rt.path.split(delimiter).filter(Boolean)) { + const target = join(directory, command); + if (await accessible(target, rt, constants.X_OK)) return target; + } + return null; +} + +async function executableCheck(command: string, rt: Runtime): Promise { + const resolvedPath = await resolveExecutable(command, rt); + return { + command, path: resolvedPath, status: resolvedPath ? 'pass' : 'fail', + errors: resolvedPath ? [] : [`${command} was not found on PATH`], + }; +} + +async function directoryCheck(agent: AgentKey, rt: Runtime): Promise { + const target = join(rt.homeDir, AGENT_META[agent].dotDir); + const readable = await accessible(target, rt); + return { + path: displayHome(target, rt.homeDir), present: readable, readable, + status: readable ? 'pass' : 'fail', errors: readable ? [] : ['global configuration directory is unavailable'], + }; +} + +async function builtInSkillsCheck(agent: AgentKey, rt: Runtime): Promise { + const relativeRoot = getGlobalSkillPath(AGENT_META[agent].skillEnv) ?? ''; + const root = join(rt.homeDir, relativeRoot); + const present: string[] = []; + for (const name of BUILTIN_SKILL_NAMES) { + if (await accessible(join(root, name, 'SKILL.md'), rt)) present.push(name); + } + const missing = BUILTIN_SKILL_NAMES.filter(name => !present.includes(name)); + return { + path: displayHome(root, rt.homeDir), required: BUILTIN_SKILL_NAMES.length, + present: present.length, missing: [...missing], status: missing.length ? 'fail' : 'pass', + errors: missing.length ? ['required built-in skills are missing'] : [], + }; +} + +async function scriptCheck(installed: string, bundled: string, rt: Runtime): Promise { + let installedText: string; + try { installedText = await rt.readFile(installed); } catch { + return { + path: displayHome(installed, rt.homeDir), present: false, readable: false, + matchesBundledAsset: false, status: 'fail', errors: ['hook script is unavailable'], + }; + } + try { + const bundledText = await rt.readFile(bundled); + const matches = installedText === bundledText; + return { + path: displayHome(installed, rt.homeDir), present: true, readable: true, + matchesBundledAsset: matches, status: matches ? 'pass' : 'fail', + errors: matches ? [] : ['hook script differs from the bundled AI DevKit asset'], + }; + } catch { + return { + path: displayHome(installed, rt.homeDir), present: true, readable: true, + matchesBundledAsset: false, status: 'fail', errors: ['bundled hook asset is unavailable'], + }; + } +} + +function containsHook(root: unknown, event: string, command: string): boolean { + const hooks = record(record(root)?.hooks); + const entries = hooks?.[event]; + if (!Array.isArray(entries)) return false; + return entries.some(entry => { + const commands = record(entry)?.hooks; + return Array.isArray(commands) && commands.some(hook => { + const item = record(hook); + return item?.type === 'command' && item.command === command; + }); + }); +} + +async function registrationCheck( + target: string, event: string, command: string, rt: Runtime, +): Promise { + try { + const parsed = JSON.parse(await rt.readFile(target)); + const valid = containsHook(parsed, event, command); + return { + path: displayHome(target, rt.homeDir), event, command, present: true, valid, + status: valid ? 'pass' : 'fail', errors: valid ? [] : ['required hook registration is missing'], + }; + } catch { + return { + path: displayHome(target, rt.homeDir), event, command, present: false, valid: false, + status: 'fail', errors: ['hook configuration is missing or invalid'], + }; + } +} + +async function mappingCheck(target: string, rt: Runtime): Promise { + let text: string; + try { text = await rt.readFile(target); } catch { + return { + path: displayHome(target, rt.homeDir), present: false, valid: false, + invalidEntries: 0, staleEntries: 0, status: 'warn', errors: ['session mapping has not been created'], + }; + } + let parsed: Record | null = null; + try { parsed = record(JSON.parse(text)); } catch { /* fixed safe error below */ } + if (!parsed) { + return { + path: displayHome(target, rt.homeDir), present: true, valid: false, + invalidEntries: 0, staleEntries: 0, status: 'fail', errors: ['session mapping is invalid'], + }; + } + let invalidEntries = 0; + let staleEntries = 0; + for (const [pid, sessionPath] of Object.entries(parsed)) { + if (!/^\d+$/.test(pid) || typeof sessionPath !== 'string' || !sessionPath) { + invalidEntries += 1; + continue; + } + if (!await accessible(sessionPath, rt)) staleEntries += 1; + } + const valid = invalidEntries === 0; + return { + path: displayHome(target, rt.homeDir), present: true, valid, invalidEntries, staleEntries, + status: !valid ? 'fail' : staleEntries ? 'warn' : 'pass', + errors: !valid ? ['session mapping contains invalid entries'] : staleEntries ? ['session mapping contains stale entries'] : [], + }; +} + +async function codexHooks(rt: Runtime): Promise { + const script = await scriptCheck( + join(rt.homeDir, '.codex', 'hooks', 'codex-session-mapping.cjs'), + join(rt.assetRoot, 'codex', 'codex-session-mapping.cjs'), rt, + ); + const registration = await registrationCheck( + join(rt.homeDir, '.codex', 'hooks.json'), 'SessionStart', + 'node ~/.codex/hooks/codex-session-mapping.cjs', rt, + ); + const mappingFile = await mappingCheck(join(rt.homeDir, '.codex', 'ai-devkit', 'sessions.json'), rt); + return { sessionMappingScript: script, registration, mappingFile, status: worstStatus([script.status, registration.status, mappingFile.status]) }; +} + +async function claudeHooks(rt: Runtime): Promise { + const script = await scriptCheck( + join(rt.homeDir, '.claude', 'hooks', 'claude-prompt-hook.js'), + join(rt.assetRoot, 'claude', 'claude-prompt-hook.js'), rt, + ); + const registration = await registrationCheck( + join(rt.homeDir, '.claude', 'settings.json'), 'PreToolUse', + 'node ~/.claude/hooks/claude-prompt-hook.js', rt, + ); + return { promptScript: script, registration, status: worstStatus([script.status, registration.status]) }; +} + +async function piHooks(rt: Runtime): Promise { + let installed = false; + try { + const result = await rt.runCommand('pi', ['list']); + installed = result.stdout.includes('@ai-devkit/pi-session-tracker'); + } catch { /* safe fixed result */ } + const mapping = await mappingCheck(join(rt.homeDir, '.pi', 'agent', 'sessions.json'), rt); + const status = worstStatus([installed ? 'pass' : 'fail', mapping.status]); + return { sessionTracker: { + package: '@ai-devkit/pi-session-tracker', installed, + registryPath: mapping.path, registryValid: mapping.valid, + invalidEntries: mapping.invalidEntries, staleEntries: mapping.staleEntries, + status, errors: [ + ...(!installed ? ['Pi session tracker is not registered'] : []), ...mapping.errors, + ], + }, status }; +} + +async function codexAuthCheck(rt: Runtime): Promise { + try { + const value = await rt.codexAuth(); + return { + state: value === true ? 'authenticated' : value === false ? 'unauthenticated' : 'unknown', + source: displayHome(join(rt.homeDir, '.codex', 'auth.json'), rt.homeDir), + status: value === true ? 'pass' : value === false ? 'fail' : 'warn', + errors: value === true ? [] : [value === false ? 'Codex is not authenticated' : 'Codex authentication is unknown'], + }; + } catch { + return { state: 'unknown', source: '~/.codex/auth.json', status: 'warn', errors: ['Codex authentication probe failed'] }; + } +} + +async function claudeAuthCheck(rt: Runtime): Promise { + try { + const result = await rt.runCommand('claude', ['auth', 'status', '--json']); + const parsed = record(JSON.parse(result.stdout)); + const authenticated = parsed?.loggedIn === true || parsed?.authenticated === true; + const unauthenticated = parsed?.loggedIn === false || parsed?.authenticated === false; + return { + state: authenticated ? 'authenticated' : unauthenticated ? 'unauthenticated' : 'unknown', + source: 'claude auth status --json', status: authenticated ? 'pass' : unauthenticated ? 'fail' : 'warn', + errors: authenticated ? [] : [unauthenticated ? 'Claude is not authenticated' : 'Claude authentication is unknown'], + }; + } catch { + return { state: 'unknown', source: 'claude auth status --json', status: 'warn', errors: ['Claude authentication probe failed'] }; + } +} + +async function piAuthCheck(rt: Runtime): Promise { + const sourcePath = join(rt.homeDir, '.pi', 'agent', 'auth.json'); + try { + const parsed = record(JSON.parse(await rt.readFile(sourcePath))); + if (!parsed) throw new Error('invalid'); + return { + state: 'unknown', source: displayHome(sourcePath, rt.homeDir), status: 'warn', + errors: ['Pi credential file is present but current authentication cannot be verified'], + }; + } catch { + return { + state: 'unauthenticated', source: displayHome(sourcePath, rt.homeDir), status: 'fail', + errors: ['Pi credential file is missing or invalid'], + }; + } +} + +async function agentCheck( + agent: AgentKey, hooks: Promise, auth: Promise, rt: Runtime, +): Promise> { + const [executable, globalConfig, builtInSkills, hookResult, authResult] = await Promise.all([ + executableCheck(AGENTS[agent].command, rt), directoryCheck(agent, rt), builtInSkillsCheck(agent, rt), hooks, auth, + ]); + return { + executable, globalConfig, auth: authResult, builtInSkills, hooks: hookResult, + status: worstStatus([executable.status, globalConfig.status, authResult.status, builtInSkills.status, hookResult.status]), + }; +} + +async function projectConfigCheck(rt: Runtime): Promise<{ check: ProjectConfigCheck; raw: Record | null }> { + const target = join(rt.cwd, '.ai-devkit.json'); + let text: string; + try { text = await rt.readFile(target); } catch { + return { raw: null, check: { + path: target, present: false, valid: false, version: null, environments: [], + status: 'fail', errors: ['project configuration is missing'], + } }; + } + let parsed: Record | null = null; + try { parsed = record(JSON.parse(text)); } catch { /* safe error below */ } + if (!parsed) return { raw: null, check: { + path: target, present: true, valid: false, version: null, environments: [], + status: 'fail', errors: ['project configuration is invalid JSON or not an object'], + } }; + const version = typeof parsed.version === 'string' ? parsed.version : null; + const environments = Array.isArray(parsed.environments) + ? parsed.environments.filter((value): value is string => typeof value === 'string') : []; + const invalidEnvironments = environments.filter(value => !isValidEnvironmentCode(value)); + const valid = version !== null && Array.isArray(parsed.environments) + && environments.length === parsed.environments.length && invalidEnvironments.length === 0; + return { raw: parsed, check: { + path: target, present: true, valid, version, environments, + status: valid ? 'pass' : 'fail', errors: valid ? [] : ['project configuration has invalid fields or environment codes'], + } }; +} + +async function globalRegistries(rt: Runtime): Promise { + const source = join(rt.homeDir, '.ai-devkit', '.ai-devkit.json'); + try { + const parsed = record(JSON.parse(await rt.readFile(source))); + if (!parsed) throw new Error('invalid'); + return { source: displayHome(source, rt.homeDir), configured: safeRegistries(parsed.registries), status: 'pass', errors: [] }; + } catch { + return { + source: displayHome(source, rt.homeDir), configured: {}, status: 'warn', + errors: ['global AI DevKit configuration is missing or invalid'], + }; + } +} + +function projectRegistries(raw: Record | null, source: string): RegistryScopeCheck { + return raw + ? { source, configured: safeRegistries(raw.registries), status: 'pass', errors: [] } + : { source, configured: {}, status: 'fail', errors: ['project registries are unavailable because project configuration is invalid'] }; +} + +function safeRegistries(raw: unknown): Record { + return Object.fromEntries(Object.entries(filterStringRecord(raw)).map(([id, value]) => { + try { + const url = new URL(value); + url.username = ''; + url.password = ''; + url.search = ''; + url.hash = ''; + return [id, url.toString()]; + } catch { + return [id, /^[\w.-]+@[\w.-]+:[^\s]+$/.test(value) ? value : '[redacted registry URL]']; + } + })); +} + +function versionParts(value: string): number[] | null { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value.trim()); + return match ? match.slice(1).map(Number) : null; +} + +function isNewer(latest: string, installed: string): boolean | null { + const left = versionParts(latest); + const right = versionParts(installed); + if (!left || !right) return null; + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] > right[index]; + } + return false; +} + +async function versionCheck(rt: Runtime): Promise { + try { + const result = await rt.runCommand('npm', ['view', 'ai-devkit', 'version']); + const latestVersion = result.stdout.trim(); + const updateAvailable = isNewer(latestVersion, rt.installedVersion); + if (updateAvailable === null) throw new Error('invalid'); + return { + installedVersion: rt.installedVersion, latestVersion, updateAvailable, + latestVersionSource: 'npm', status: 'pass', errors: [], + }; + } catch { + return { + installedVersion: rt.installedVersion, latestVersion: null, updateAvailable: null, + latestVersionSource: 'npm', status: 'warn', errors: ['latest npm version is unavailable'], + }; + } +} + +async function tmuxCheck(rt: Runtime): Promise { + const executable = await resolveExecutable('tmux', rt); + if (!executable) return { + path: null, available: false, version: null, status: 'fail', errors: ['tmux was not found on PATH'], + }; + try { + const result = await rt.runCommand('tmux', ['-V']); + const version = result.stdout.trim().replace(/^tmux\s+/i, '') || null; + return { path: executable, available: true, version, status: 'pass', errors: [] }; + } catch { + return { path: executable, available: false, version: null, status: 'fail', errors: ['tmux version probe failed'] }; + } +} + +function nonEmpty(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0; +} + +function channelConnection(name: string, value: unknown): ChannelConnection { + const entry = record(value); + const type = typeof entry?.type === 'string' ? entry.type : 'unknown'; + const enabled = entry?.enabled === true; + const config = record(entry?.config); + let credentialsPresent = false; + let authorized: boolean | null = null; + let schemaValid = Boolean(entry && config && typeof entry.enabled === 'boolean'); + if (type === 'telegram') { + credentialsPresent = nonEmpty(config?.botToken) && nonEmpty(config?.botUsername); + authorized = typeof config?.authorizedChatId === 'number'; + schemaValid = schemaValid && credentialsPresent; + } else if (type === 'slack') { + credentialsPresent = typeof config?.appToken === 'string' && config.appToken.startsWith('xapp-') + && typeof config?.botToken === 'string' && config.botToken.startsWith('xoxb-'); + authorized = null; + schemaValid = schemaValid && credentialsPresent && nonEmpty(config?.botUserId) && nonEmpty(config?.workspaceId) + && config?.transport === 'socket-mode' && config?.audience === 'dm'; + } else { + schemaValid = false; + } + const ready = enabled && schemaValid && (authorized !== false); + return { + name, type, enabled, credentialsPresent, authorized, ready, + status: ready ? 'pass' : enabled ? 'fail' : 'warn', + errors: ready ? [] : [enabled ? 'channel configuration is not ready' : 'channel is disabled'], + }; +} + +async function channelsCheck(rt: Runtime): Promise { + const target = join(rt.homeDir, '.ai-devkit', 'channels.json'); + let text: string; + try { text = await rt.readFile(target); } catch { + const config: ChannelConfigCheck = { + path: displayHome(target, rt.homeDir), present: false, validJson: false, validSchema: false, + status: 'warn', errors: ['channel configuration has not been created'], + }; + return { config, connections: [], readyCount: 0, status: config.status }; + } + let parsed: Record | null = null; + try { parsed = record(JSON.parse(text)); } catch { /* fixed safe error below */ } + const channelRecord = record(parsed?.channels); + if (!parsed || !channelRecord) { + const config: ChannelConfigCheck = { + path: displayHome(target, rt.homeDir), present: true, validJson: parsed !== null, + validSchema: false, status: 'fail', errors: ['channel configuration is invalid'], + }; + return { config, connections: [], readyCount: 0, status: 'fail' }; + } + const connections = Object.entries(channelRecord).map(([name, entry]) => channelConnection(name, entry)); + const validSchema = connections.every(item => item.status !== 'fail'); + const config: ChannelConfigCheck = { + path: displayHome(target, rt.homeDir), present: true, validJson: true, validSchema: true, + status: validSchema ? 'pass' : 'fail', errors: validSchema ? [] : ['one or more channel entries are invalid'], + }; + config.validSchema = validSchema; + return { + config, connections, readyCount: connections.filter(item => item.ready).length, + status: worstStatus([config.status, ...connections.map(item => item.status)]), + }; +} + +function leafStatuses(report: Omit): CheckStatus[] { + const { codex, pi, claude } = report.agents; + return [ + report.aiDevkit.status, report.project.config.status, + codex.executable.status, codex.globalConfig.status, codex.auth.status, codex.builtInSkills.status, + codex.hooks.sessionMappingScript.status, codex.hooks.registration.status, codex.hooks.mappingFile.status, + pi.executable.status, pi.globalConfig.status, pi.auth.status, pi.builtInSkills.status, + pi.hooks.sessionTracker.status, + claude.executable.status, claude.globalConfig.status, claude.auth.status, claude.builtInSkills.status, + claude.hooks.promptScript.status, claude.hooks.registration.status, + report.tmux.status, report.registries.project.status, report.registries.global.status, + report.channels.config.status, ...report.channels.connections.map(item => item.status), + ]; +} + +export async function getStatusReport(options: StatusServiceOptions = {}): Promise { + const rt = runtime(options); + const projectPromise = projectConfigCheck(rt); + const [project, codex, pi, claude, aiDevkit, tmux, globalRegistry, channels] = await Promise.all([ + projectPromise, + agentCheck('codex', codexHooks(rt), codexAuthCheck(rt), rt), + agentCheck('pi', piHooks(rt), piAuthCheck(rt), rt), + agentCheck('claude', claudeHooks(rt), claudeAuthCheck(rt), rt), + versionCheck(rt), tmuxCheck(rt), globalRegistries(rt), channelsCheck(rt), + ]); + const registries: RegistriesCheck = { + project: projectRegistries(project.raw, project.check.path), global: globalRegistry, + status: worstStatus([project.raw ? 'pass' : 'fail', globalRegistry.status]), + }; + const partial = { + generatedAt: rt.now().toISOString(), aiDevkit, + project: { cwd: rt.cwd, config: project.check }, agents: { codex, pi, claude }, + tmux, registries, channels, + }; + const statuses = leafStatuses(partial); + return { + ...partial, overall: worstStatus(statuses), + checks: { + passed: statuses.filter(status => status === 'pass').length, + warnings: statuses.filter(status => status === 'warn').length, + failed: statuses.filter(status => status === 'fail').length, + }, + }; +}