| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- import { type Command } from 'commander';
- import { join } from 'node:path';
- import { readCliVersion } from '../lib/version.js';
- import { readManifest } from '../lib/manifest.js';
- import { computeFileHash, hashesMatch } from '../lib/sha256.js';
- import { detectIdes } from '../lib/ide-detect.js';
- import { log, info, warn, bold, dim, success } from '../ui/logger.js';
- import type { ManifestFile, ManifestFileType } from '../lib/manifest.js';
- import type { DetectedIde } from '../lib/ide-detect.js';
- // ── Types ─────────────────────────────────────────────────────────────────────
- export type StatusOptions = {
- verbose: boolean;
- };
- type ComponentCounts = {
- agents: number;
- context: number;
- skills: number;
- other: number;
- total: number;
- };
- type ModifiedResult = {
- count: number;
- paths: string[];
- };
- // ── Pure counters ─────────────────────────────────────────────────────────────
- /** Counts manifest entries by their file type. Pure function. */
- const countComponents = (manifest: ManifestFile): ComponentCounts => {
- const entries = Object.values(manifest.files);
- const byType = (t: ManifestFileType): number =>
- entries.filter((e) => e.type === t).length;
- const agents = byType('agent');
- const context = byType('context');
- const skills = byType('skill');
- const other = entries.length - agents - context - skills;
- return { agents, context, skills, other, total: entries.length };
- };
- // ── SHA256 diff check ─────────────────────────────────────────────────────────
- /**
- * Compares each manifest file's stored hash against the file on disk.
- * Returns the count and paths of files that have been locally modified.
- * Wraps computeFileHash in try/catch — deleted files are treated as modified.
- */
- const findModifiedFiles = async (
- projectRoot: string,
- manifest: ManifestFile,
- ): Promise<ModifiedResult> => {
- const entries = Object.entries(manifest.files);
- const checks = await Promise.all(
- entries.map(async ([relPath, entry]) => {
- const absPath = join(projectRoot, relPath);
- try {
- const diskHash = await computeFileHash(absPath);
- return !hashesMatch(diskHash, entry.sha256) ? relPath : null;
- } catch {
- // File deleted or unreadable — counts as modified
- return relPath;
- }
- }),
- );
- const paths = checks.filter((p): p is string => p !== null);
- return { count: paths.length, paths };
- };
- // ── IDE formatter ─────────────────────────────────────────────────────────────
- /** Formats the list of detected IDEs into a display string. Pure function. */
- const formatIdeList = (ides: DetectedIde[]): string => {
- const detected = ides.filter((i) => i.detected);
- const notDetected = ides.filter((i) => !i.detected);
- if (detected.length === 0) {
- return 'None detected — run `oac apply <ide>` to set up';
- }
- const detectedNames = detected.map((i) => i.type).join(', ');
- if (notDetected.length === 0) return detectedNames;
- const notDetectedNames = notDetected.map((i) => i.type).join(', ');
- return `${detectedNames} (not detected: ${notDetectedNames})`;
- };
- // ── Update check ──────────────────────────────────────────────────────────────
- /** Returns a human-readable update status line. Pure function. */
- const formatUpdateStatus = (manifestVersion: string, cliVersion: string): string =>
- manifestVersion === cliVersion
- ? `Up to date (v${cliVersion})`
- : `Available — manifest has v${manifestVersion}, CLI is v${cliVersion} (run 'oac update')`;
- // ── Timestamp formatter ───────────────────────────────────────────────────────
- /** Formats an ISO timestamp into a readable local date string. Pure function. */
- const formatTimestamp = (iso: string): string => {
- try {
- return new Date(iso).toLocaleString();
- } catch {
- return iso;
- }
- };
- // ── Display ───────────────────────────────────────────────────────────────────
- /** Prints the one-screen status summary. Side-effect only. */
- const printStatus = (
- cliVersion: string,
- projectRoot: string,
- manifest: ManifestFile,
- counts: ComponentCounts,
- modified: ModifiedResult,
- ides: DetectedIde[],
- ): void => {
- const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
- const displayPath = projectRoot.startsWith(homeDir)
- ? `~${projectRoot.slice(homeDir.length)}`
- : projectRoot;
- log('');
- bold(`OAC v${cliVersion} — ${displayPath}`);
- log('');
- info(`Agents: ${counts.agents} installed`);
- info(`Context: ${counts.context} files`);
- info(`Skills: ${counts.skills} installed`);
- if (modified.count > 0) {
- warn(`Modified: ${modified.count} file${modified.count !== 1 ? 's' : ''} have local changes`);
- } else {
- success(`Modified: No local changes`);
- }
- info(`Updates: ${formatUpdateStatus(manifest.oacVersion, cliVersion)}`);
- info(`IDEs: ${formatIdeList(ides)}`);
- info(`Last updated: ${formatTimestamp(manifest.updatedAt)}`);
- log('');
- dim(` Run 'oac doctor' for full health check`);
- log('');
- };
- /** Prints verbose details about modified files. Side-effect only. */
- const printVerboseModified = (modified: ModifiedResult): void => {
- if (modified.count === 0) return;
- dim(' Modified files:');
- for (const p of modified.paths) {
- dim(` • ${p}`);
- }
- log('');
- };
- // ── Command handler ───────────────────────────────────────────────────────────
- /**
- * Implements `oac status`:
- * 1. Reads manifest — exits early with helpful message if not initialized
- * 2. Counts components by type
- * 3. Checks for user-modified files via SHA256 comparison
- * 4. Detects IDEs
- * 5. Prints one-screen summary
- * Always exits 0 (read-only command).
- */
- export async function statusCommand(options: StatusOptions): Promise<void> {
- const projectRoot = process.cwd();
- const cliVersion = readCliVersion();
- // Step 1: read manifest — not initialized is a valid state, not an error
- let manifest: ManifestFile | null;
- try {
- manifest = await readManifest(projectRoot);
- } catch (err: unknown) {
- const msg = err instanceof Error ? err.message : String(err);
- log(` OAC manifest is invalid: ${msg}`);
- log(` Run 'oac init' to reset, or fix .oac/manifest.json manually.`);
- process.exit(0);
- return; // unreachable — satisfies TypeScript
- }
- if (manifest === null) {
- log('');
- log(' OAC not initialized. Run \'oac init\' to get started.');
- log('');
- process.exit(0);
- }
- // Step 2: count components
- const counts = countComponents(manifest);
- // Steps 3 & 4: check modified files and detect IDEs in parallel
- const [modified, ides] = await Promise.all([
- findModifiedFiles(projectRoot, manifest),
- detectIdes(projectRoot),
- ]);
- // Step 5: print summary
- printStatus(cliVersion, projectRoot, manifest, counts, modified, ides);
- if (options.verbose) {
- printVerboseModified(modified);
- }
- process.exit(0);
- }
- // ── Commander registration ────────────────────────────────────────────────────
- /**
- * Registers the `oac status` command on the given Commander program.
- * Called by the CLI entry point (index.ts).
- */
- export function registerStatusCommand(program: Command): void {
- program
- .command('status')
- .description('Show a one-screen summary of your OAC installation')
- .option('--verbose', 'Show details about modified files', false)
- .action(async (opts: { verbose?: boolean }) => {
- await statusCommand({ verbose: opts.verbose ?? false });
- });
- }
|