status.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import { type Command } from 'commander';
  2. import { join } from 'node:path';
  3. import { readCliVersion } from '../lib/version.js';
  4. import { readManifest } from '../lib/manifest.js';
  5. import { computeFileHash, hashesMatch } from '../lib/sha256.js';
  6. import { detectIdes } from '../lib/ide-detect.js';
  7. import { log, info, warn, bold, dim, success } from '../ui/logger.js';
  8. import type { ManifestFile, ManifestFileType } from '../lib/manifest.js';
  9. import type { DetectedIde } from '../lib/ide-detect.js';
  10. // ── Types ─────────────────────────────────────────────────────────────────────
  11. export type StatusOptions = {
  12. verbose: boolean;
  13. };
  14. type ComponentCounts = {
  15. agents: number;
  16. context: number;
  17. skills: number;
  18. other: number;
  19. total: number;
  20. };
  21. type ModifiedResult = {
  22. count: number;
  23. paths: string[];
  24. };
  25. // ── Pure counters ─────────────────────────────────────────────────────────────
  26. /** Counts manifest entries by their file type. Pure function. */
  27. const countComponents = (manifest: ManifestFile): ComponentCounts => {
  28. const entries = Object.values(manifest.files);
  29. const byType = (t: ManifestFileType): number =>
  30. entries.filter((e) => e.type === t).length;
  31. const agents = byType('agent');
  32. const context = byType('context');
  33. const skills = byType('skill');
  34. const other = entries.length - agents - context - skills;
  35. return { agents, context, skills, other, total: entries.length };
  36. };
  37. // ── SHA256 diff check ─────────────────────────────────────────────────────────
  38. /**
  39. * Compares each manifest file's stored hash against the file on disk.
  40. * Returns the count and paths of files that have been locally modified.
  41. * Wraps computeFileHash in try/catch — deleted files are treated as modified.
  42. */
  43. const findModifiedFiles = async (
  44. projectRoot: string,
  45. manifest: ManifestFile,
  46. ): Promise<ModifiedResult> => {
  47. const entries = Object.entries(manifest.files);
  48. const checks = await Promise.all(
  49. entries.map(async ([relPath, entry]) => {
  50. const absPath = join(projectRoot, relPath);
  51. try {
  52. const diskHash = await computeFileHash(absPath);
  53. return !hashesMatch(diskHash, entry.sha256) ? relPath : null;
  54. } catch {
  55. // File deleted or unreadable — counts as modified
  56. return relPath;
  57. }
  58. }),
  59. );
  60. const paths = checks.filter((p): p is string => p !== null);
  61. return { count: paths.length, paths };
  62. };
  63. // ── IDE formatter ─────────────────────────────────────────────────────────────
  64. /** Formats the list of detected IDEs into a display string. Pure function. */
  65. const formatIdeList = (ides: DetectedIde[]): string => {
  66. const detected = ides.filter((i) => i.detected);
  67. const notDetected = ides.filter((i) => !i.detected);
  68. if (detected.length === 0) {
  69. return 'None detected — run `oac apply <ide>` to set up';
  70. }
  71. const detectedNames = detected.map((i) => i.type).join(', ');
  72. if (notDetected.length === 0) return detectedNames;
  73. const notDetectedNames = notDetected.map((i) => i.type).join(', ');
  74. return `${detectedNames} (not detected: ${notDetectedNames})`;
  75. };
  76. // ── Update check ──────────────────────────────────────────────────────────────
  77. /** Returns a human-readable update status line. Pure function. */
  78. const formatUpdateStatus = (manifestVersion: string, cliVersion: string): string =>
  79. manifestVersion === cliVersion
  80. ? `Up to date (v${cliVersion})`
  81. : `Available — manifest has v${manifestVersion}, CLI is v${cliVersion} (run 'oac update')`;
  82. // ── Timestamp formatter ───────────────────────────────────────────────────────
  83. /** Formats an ISO timestamp into a readable local date string. Pure function. */
  84. const formatTimestamp = (iso: string): string => {
  85. try {
  86. return new Date(iso).toLocaleString();
  87. } catch {
  88. return iso;
  89. }
  90. };
  91. // ── Display ───────────────────────────────────────────────────────────────────
  92. /** Prints the one-screen status summary. Side-effect only. */
  93. const printStatus = (
  94. cliVersion: string,
  95. projectRoot: string,
  96. manifest: ManifestFile,
  97. counts: ComponentCounts,
  98. modified: ModifiedResult,
  99. ides: DetectedIde[],
  100. ): void => {
  101. const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
  102. const displayPath = projectRoot.startsWith(homeDir)
  103. ? `~${projectRoot.slice(homeDir.length)}`
  104. : projectRoot;
  105. log('');
  106. bold(`OAC v${cliVersion} — ${displayPath}`);
  107. log('');
  108. info(`Agents: ${counts.agents} installed`);
  109. info(`Context: ${counts.context} files`);
  110. info(`Skills: ${counts.skills} installed`);
  111. if (modified.count > 0) {
  112. warn(`Modified: ${modified.count} file${modified.count !== 1 ? 's' : ''} have local changes`);
  113. } else {
  114. success(`Modified: No local changes`);
  115. }
  116. info(`Updates: ${formatUpdateStatus(manifest.oacVersion, cliVersion)}`);
  117. info(`IDEs: ${formatIdeList(ides)}`);
  118. info(`Last updated: ${formatTimestamp(manifest.updatedAt)}`);
  119. log('');
  120. dim(` Run 'oac doctor' for full health check`);
  121. log('');
  122. };
  123. /** Prints verbose details about modified files. Side-effect only. */
  124. const printVerboseModified = (modified: ModifiedResult): void => {
  125. if (modified.count === 0) return;
  126. dim(' Modified files:');
  127. for (const p of modified.paths) {
  128. dim(` • ${p}`);
  129. }
  130. log('');
  131. };
  132. // ── Command handler ───────────────────────────────────────────────────────────
  133. /**
  134. * Implements `oac status`:
  135. * 1. Reads manifest — exits early with helpful message if not initialized
  136. * 2. Counts components by type
  137. * 3. Checks for user-modified files via SHA256 comparison
  138. * 4. Detects IDEs
  139. * 5. Prints one-screen summary
  140. * Always exits 0 (read-only command).
  141. */
  142. export async function statusCommand(options: StatusOptions): Promise<void> {
  143. const projectRoot = process.cwd();
  144. const cliVersion = readCliVersion();
  145. // Step 1: read manifest — not initialized is a valid state, not an error
  146. let manifest: ManifestFile | null;
  147. try {
  148. manifest = await readManifest(projectRoot);
  149. } catch (err: unknown) {
  150. const msg = err instanceof Error ? err.message : String(err);
  151. log(` OAC manifest is invalid: ${msg}`);
  152. log(` Run 'oac init' to reset, or fix .oac/manifest.json manually.`);
  153. process.exit(0);
  154. return; // unreachable — satisfies TypeScript
  155. }
  156. if (manifest === null) {
  157. log('');
  158. log(' OAC not initialized. Run \'oac init\' to get started.');
  159. log('');
  160. process.exit(0);
  161. }
  162. // Step 2: count components
  163. const counts = countComponents(manifest);
  164. // Steps 3 & 4: check modified files and detect IDEs in parallel
  165. const [modified, ides] = await Promise.all([
  166. findModifiedFiles(projectRoot, manifest),
  167. detectIdes(projectRoot),
  168. ]);
  169. // Step 5: print summary
  170. printStatus(cliVersion, projectRoot, manifest, counts, modified, ides);
  171. if (options.verbose) {
  172. printVerboseModified(modified);
  173. }
  174. process.exit(0);
  175. }
  176. // ── Commander registration ────────────────────────────────────────────────────
  177. /**
  178. * Registers the `oac status` command on the given Commander program.
  179. * Called by the CLI entry point (index.ts).
  180. */
  181. export function registerStatusCommand(program: Command): void {
  182. program
  183. .command('status')
  184. .description('Show a one-screen summary of your OAC installation')
  185. .option('--verbose', 'Show details about modified files', false)
  186. .action(async (opts: { verbose?: boolean }) => {
  187. await statusCommand({ verbose: opts.verbose ?? false });
  188. });
  189. }