@os-eco/overstory-cli 0.6.9 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
+ import { DEFAULT_QUALITY_GATES } from "../config.ts";
3
4
  import { AgentError } from "../errors.ts";
4
5
  import type { OverlayConfig, QualityGate } from "../types.ts";
5
6
 
@@ -76,12 +77,65 @@ Your parent has already gathered the context you need.
76
77
  * a lightweight section that only tells them to close the issue and report.
77
78
  * Writable agents get the full quality gates (tests, lint, build, commit).
78
79
  */
79
- /** Default quality gates used when none are configured. */
80
- const DEFAULT_GATES: QualityGate[] = [
81
- { name: "Tests", command: "bun test", description: "all tests must pass" },
82
- { name: "Lint", command: "bun run lint", description: "zero errors" },
83
- { name: "Typecheck", command: "bun run typecheck", description: "no TypeScript errors" },
84
- ];
80
+ /**
81
+ * Resolve quality gates: use provided gates if non-empty, otherwise fall back to defaults.
82
+ */
83
+ function resolveGates(gates: QualityGate[] | undefined): QualityGate[] {
84
+ return gates && gates.length > 0 ? gates : DEFAULT_QUALITY_GATES;
85
+ }
86
+
87
+ /**
88
+ * Format quality gates as inline backtick-delimited commands for prose sections.
89
+ * Example: `bun test`, `bun run lint`, `bun run typecheck`
90
+ */
91
+ export function formatQualityGatesInline(gates: QualityGate[] | undefined): string {
92
+ return resolveGates(gates)
93
+ .map((g) => `\`${g.command}\``)
94
+ .join(", ");
95
+ }
96
+
97
+ /**
98
+ * Format quality gates as a numbered step list for completion-protocol sections.
99
+ * Example:
100
+ * 1. Run `bun test` -- all tests must pass.
101
+ * 2. Run `bun run lint` -- lint and formatting must be clean.
102
+ */
103
+ export function formatQualityGatesSteps(gates: QualityGate[] | undefined): string {
104
+ return resolveGates(gates)
105
+ .map((g, i) => `${i + 1}. Run \`${g.command}\` -- ${g.description}.`)
106
+ .join("\n");
107
+ }
108
+
109
+ /**
110
+ * Format quality gates as a bash code block for workflow sections.
111
+ * Example:
112
+ * ```bash
113
+ * bun test # All tests must pass
114
+ * bun run lint # Lint and format must be clean
115
+ * ```
116
+ */
117
+ export function formatQualityGatesBash(gates: QualityGate[] | undefined): string {
118
+ const resolved = resolveGates(gates);
119
+ // Pad commands to align comments
120
+ const maxLen = Math.max(...resolved.map((g) => g.command.length));
121
+ const lines = resolved.map((g) => {
122
+ const padded = g.command.padEnd(maxLen + 2);
123
+ return `${padded}# ${g.description[0]?.toUpperCase() ?? ""}${g.description.slice(1)}`;
124
+ });
125
+ return ["```bash", ...lines, "```"].join("\n");
126
+ }
127
+
128
+ /**
129
+ * Format quality gates as a bullet list for capabilities sections.
130
+ * Example:
131
+ * - `bun test` (run tests)
132
+ * - `bun run lint` (lint and format check via biome)
133
+ */
134
+ export function formatQualityGatesCapabilities(gates: QualityGate[] | undefined): string {
135
+ return resolveGates(gates)
136
+ .map((g) => ` - \`${g.command}\` (${g.description})`)
137
+ .join("\n");
138
+ }
85
139
 
86
140
  function formatQualityGates(config: OverlayConfig): string {
87
141
  if (READ_ONLY_CAPABILITIES.has(config.capability)) {
@@ -99,7 +153,9 @@ function formatQualityGates(config: OverlayConfig): string {
99
153
  }
100
154
 
101
155
  const gates =
102
- config.qualityGates && config.qualityGates.length > 0 ? config.qualityGates : DEFAULT_GATES;
156
+ config.qualityGates && config.qualityGates.length > 0
157
+ ? config.qualityGates
158
+ : DEFAULT_QUALITY_GATES;
103
159
 
104
160
  const gateLines = gates.map(
105
161
  (gate, i) => `${i + 1}. **${gate.name}:** \`${gate.command}\` — ${gate.description}`,
@@ -220,6 +276,10 @@ export async function generateOverlay(config: OverlayConfig): Promise<string> {
220
276
  "{{SPEC_INSTRUCTION}}": specInstruction,
221
277
  "{{SKIP_SCOUT}}": config.skipScout ? SKIP_SCOUT_SECTION : "",
222
278
  "{{BASE_DEFINITION}}": config.baseDefinition,
279
+ "{{QUALITY_GATE_INLINE}}": formatQualityGatesInline(config.qualityGates),
280
+ "{{QUALITY_GATE_STEPS}}": formatQualityGatesSteps(config.qualityGates),
281
+ "{{QUALITY_GATE_BASH}}": formatQualityGatesBash(config.qualityGates),
282
+ "{{QUALITY_GATE_CAPABILITIES}}": formatQualityGatesCapabilities(config.qualityGates),
223
283
  "{{TRACKER_CLI}}": config.trackerCli ?? "bd",
224
284
  "{{TRACKER_NAME}}": config.trackerName ?? "beads",
225
285
  };
@@ -203,54 +203,42 @@ describe("completionsCommand", () => {
203
203
  });
204
204
 
205
205
  it("should exit with error for missing shell argument", () => {
206
- const originalExit = process.exit;
206
+ const originalExitCode = process.exitCode;
207
207
  const originalStderr = process.stderr.write;
208
- let exitCode: number | undefined;
209
208
  let stderrOutput = "";
210
209
 
211
- process.exit = mock((code?: string | number | null | undefined) => {
212
- exitCode = typeof code === "number" ? code : 1;
213
- throw new Error("process.exit called");
214
- }) as never;
215
-
216
210
  process.stderr.write = mock((chunk: unknown) => {
217
211
  stderrOutput += String(chunk);
218
212
  return true;
219
213
  });
220
214
 
221
215
  try {
222
- expect(() => completionsCommand([])).toThrow("process.exit called");
223
- expect(exitCode).toBe(1);
216
+ completionsCommand([]);
217
+ expect(process.exitCode).toBe(1);
224
218
  expect(stderrOutput).toContain("missing shell argument");
225
219
  } finally {
226
- process.exit = originalExit;
220
+ process.exitCode = originalExitCode;
227
221
  process.stderr.write = originalStderr;
228
222
  }
229
223
  });
230
224
 
231
225
  it("should exit with error for unknown shell", () => {
232
- const originalExit = process.exit;
226
+ const originalExitCode = process.exitCode;
233
227
  const originalStderr = process.stderr.write;
234
- let exitCode: number | undefined;
235
228
  let stderrOutput = "";
236
229
 
237
- process.exit = mock((code?: string | number | null | undefined) => {
238
- exitCode = typeof code === "number" ? code : 1;
239
- throw new Error("process.exit called");
240
- }) as never;
241
-
242
230
  process.stderr.write = mock((chunk: unknown) => {
243
231
  stderrOutput += String(chunk);
244
232
  return true;
245
233
  });
246
234
 
247
235
  try {
248
- expect(() => completionsCommand(["powershell"])).toThrow("process.exit called");
249
- expect(exitCode).toBe(1);
236
+ completionsCommand(["powershell"]);
237
+ expect(process.exitCode).toBe(1);
250
238
  expect(stderrOutput).toContain("unknown shell");
251
239
  expect(stderrOutput).toContain("powershell");
252
240
  } finally {
253
- process.exit = originalExit;
241
+ process.exitCode = originalExitCode;
254
242
  process.stderr.write = originalStderr;
255
243
  }
256
244
  });
@@ -874,7 +874,8 @@ export function completionsCommand(args: string[]): void {
874
874
 
875
875
  if (!shell) {
876
876
  printError("missing shell argument", "Usage: ov --completions <bash|zsh|fish>");
877
- process.exit(1);
877
+ process.exitCode = 1;
878
+ return;
878
879
  }
879
880
 
880
881
  let script: string;
@@ -890,7 +891,8 @@ export function completionsCommand(args: string[]): void {
890
891
  break;
891
892
  default:
892
893
  printError(`unknown shell '${shell}'`, "Supported shells: bash, zsh, fish");
893
- process.exit(1);
894
+ process.exitCode = 1;
895
+ return;
894
896
  }
895
897
 
896
898
  process.stdout.write(script);
@@ -12,6 +12,7 @@ import { checkConfig } from "../doctor/config-check.ts";
12
12
  import { checkConsistency } from "../doctor/consistency.ts";
13
13
  import { checkDatabases } from "../doctor/databases.ts";
14
14
  import { checkDependencies } from "../doctor/dependencies.ts";
15
+ import { checkEcosystem } from "../doctor/ecosystem.ts";
15
16
  import { checkLogs } from "../doctor/logs.ts";
16
17
  import { checkMergeQueue } from "../doctor/merge-queue.ts";
17
18
  import { checkStructure } from "../doctor/structure.ts";
@@ -32,8 +33,25 @@ const ALL_CHECKS: Array<{ category: DoctorCategory; fn: DoctorCheckFn }> = [
32
33
  { category: "merge", fn: checkMergeQueue },
33
34
  { category: "logs", fn: checkLogs },
34
35
  { category: "version", fn: checkVersion },
36
+ { category: "ecosystem", fn: checkEcosystem },
35
37
  ];
36
38
 
39
+ /**
40
+ * Execute all fix functions on non-passing fixable checks.
41
+ * Returns a list of human-readable actions taken.
42
+ */
43
+ async function applyFixes(checks: DoctorCheck[]): Promise<string[]> {
44
+ const fixable = checks.filter((c) => c.fixable && c.status !== "pass" && c.fix);
45
+ const fixed: string[] = [];
46
+ for (const check of fixable) {
47
+ if (check.fix) {
48
+ const actions = await check.fix();
49
+ fixed.push(...actions);
50
+ }
51
+ }
52
+ return fixed;
53
+ }
54
+
37
55
  /**
38
56
  * Format human-readable output for doctor checks.
39
57
  */
@@ -41,6 +59,7 @@ function printHumanReadable(
41
59
  checks: DoctorCheck[],
42
60
  verbose: boolean,
43
61
  checkRegistry: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
62
+ fixedItems?: string[],
44
63
  ): void {
45
64
  const w = process.stdout.write.bind(process.stdout);
46
65
 
@@ -105,17 +124,28 @@ function printHumanReadable(
105
124
  w(
106
125
  `${color.bold("Summary:")} ${color.green(`${pass} passed`)}, ${color.yellow(`${warn} warning${warn === 1 ? "" : "s"}`)}, ${color.red(`${fail} failure${fail === 1 ? "" : "s"}`)}\n`,
107
126
  );
127
+
128
+ if (fixedItems && fixedItems.length > 0) {
129
+ w(`\n${color.bold("Fixed:")}\n`);
130
+ for (const item of fixedItems) {
131
+ w(` ${color.green("-")} ${item}\n`);
132
+ }
133
+ }
108
134
  }
109
135
 
110
136
  /**
111
137
  * Format JSON output for doctor checks.
112
138
  */
113
- function printJSON(checks: DoctorCheck[]): void {
139
+ function printJSON(checks: DoctorCheck[], fixed?: string[]): void {
114
140
  const pass = checks.filter((c) => c.status === "pass").length;
115
141
  const warn = checks.filter((c) => c.status === "warn").length;
116
142
  const fail = checks.filter((c) => c.status === "fail").length;
117
143
 
118
- jsonOutput("doctor", { checks, summary: { pass, warn, fail } });
144
+ jsonOutput("doctor", {
145
+ checks,
146
+ summary: { pass, warn, fail },
147
+ ...(fixed && fixed.length > 0 ? { fixed } : {}),
148
+ });
119
149
  }
120
150
 
121
151
  /** Options for dependency injection in doctorCommand. */
@@ -133,59 +163,78 @@ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
133
163
  .option("--json", "Output as JSON")
134
164
  .option("--verbose", "Show passing checks (default: only problems)")
135
165
  .option("--category <name>", "Run only one category")
166
+ .option("--fix", "Attempt to auto-fix issues")
136
167
  .addHelpText(
137
168
  "after",
138
- "\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version",
169
+ "\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem",
139
170
  )
140
- .action(async (opts: { json?: boolean; verbose?: boolean; category?: string }) => {
141
- const json = opts.json ?? false;
142
- const verbose = opts.verbose ?? false;
143
- const categoryFilter = opts.category;
144
-
145
- // Validate category filter if provided
146
- if (categoryFilter !== undefined) {
147
- const validCategories = ALL_CHECKS.map((c) => c.category);
148
- if (!validCategories.includes(categoryFilter as DoctorCategory)) {
149
- throw new ValidationError(
150
- `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
151
- {
152
- field: "category",
153
- value: categoryFilter,
154
- },
155
- );
171
+ .action(
172
+ async (opts: { json?: boolean; verbose?: boolean; category?: string; fix?: boolean }) => {
173
+ const json = opts.json ?? false;
174
+ const verbose = opts.verbose ?? false;
175
+ const categoryFilter = opts.category;
176
+ const fix = opts.fix ?? false;
177
+
178
+ // Validate category filter if provided
179
+ if (categoryFilter !== undefined) {
180
+ const validCategories = ALL_CHECKS.map((c) => c.category);
181
+ if (!validCategories.includes(categoryFilter as DoctorCategory)) {
182
+ throw new ValidationError(
183
+ `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
184
+ {
185
+ field: "category",
186
+ value: categoryFilter,
187
+ },
188
+ );
189
+ }
156
190
  }
157
- }
158
191
 
159
- const cwd = process.cwd();
160
- const config = await loadConfig(cwd);
161
- const overstoryDir = join(config.project.root, ".overstory");
162
-
163
- // Filter checks by category if specified
164
- const allChecks = options?.checkRunners ?? ALL_CHECKS;
165
- const checksToRun = categoryFilter
166
- ? allChecks.filter((c) => c.category === categoryFilter)
167
- : allChecks;
168
-
169
- // Run all checks sequentially
170
- const results: DoctorCheck[] = [];
171
- for (const { fn } of checksToRun) {
172
- const checkResults = await fn(config, overstoryDir);
173
- results.push(...checkResults);
174
- }
192
+ const cwd = process.cwd();
193
+ const config = await loadConfig(cwd);
194
+ const overstoryDir = join(config.project.root, ".overstory");
175
195
 
176
- // Output results
177
- if (json) {
178
- printJSON(results);
179
- } else {
180
- printHumanReadable(results, verbose, allChecks);
181
- }
196
+ // Filter checks by category if specified
197
+ const allChecks = options?.checkRunners ?? ALL_CHECKS;
198
+ const checksToRun = categoryFilter
199
+ ? allChecks.filter((c) => c.category === categoryFilter)
200
+ : allChecks;
182
201
 
183
- // Set exit code if any check failed
184
- const hasFailures = results.some((c) => c.status === "fail");
185
- if (hasFailures) {
186
- process.exitCode = 1;
187
- }
188
- });
202
+ // Run all checks sequentially
203
+ let results: DoctorCheck[] = [];
204
+ for (const { fn } of checksToRun) {
205
+ const checkResults = await fn(config, overstoryDir);
206
+ results.push(...checkResults);
207
+ }
208
+
209
+ // Apply fixes if requested
210
+ let fixedItems: string[] | undefined;
211
+ if (fix) {
212
+ const applied = await applyFixes(results);
213
+ if (applied.length > 0) {
214
+ fixedItems = applied;
215
+ // Re-run all checks to get fresh results after fixes
216
+ results = [];
217
+ for (const { fn } of checksToRun) {
218
+ const checkResults = await fn(config, overstoryDir);
219
+ results.push(...checkResults);
220
+ }
221
+ }
222
+ }
223
+
224
+ // Output results
225
+ if (json) {
226
+ printJSON(results, fixedItems);
227
+ } else {
228
+ printHumanReadable(results, verbose, allChecks, fixedItems);
229
+ }
230
+
231
+ // Set exit code if any check failed
232
+ const hasFailures = results.some((c) => c.status === "fail");
233
+ if (hasFailures) {
234
+ process.exitCode = 1;
235
+ }
236
+ },
237
+ );
189
238
  }
190
239
 
191
240
  /**
@@ -0,0 +1,291 @@
1
+ /**
2
+ * CLI command: ov ecosystem
3
+ *
4
+ * Shows a summary dashboard of all installed os-eco tools: version, update
5
+ * status (latest vs outdated), and doctor health (overstory only).
6
+ */
7
+
8
+ import { Command } from "commander";
9
+ import { jsonError, jsonOutput } from "../json.ts";
10
+ import { accent, brand, color, muted } from "../logging/color.ts";
11
+
12
+ const TOOLS = [
13
+ { name: "overstory", cli: "ov", npm: "@os-eco/overstory-cli" },
14
+ { name: "mulch", cli: "ml", npm: "@os-eco/mulch-cli" },
15
+ { name: "seeds", cli: "sd", npm: "@os-eco/seeds-cli" },
16
+ { name: "canopy", cli: "cn", npm: "@os-eco/canopy-cli" },
17
+ ] as const;
18
+
19
+ export interface EcosystemOptions {
20
+ json?: boolean;
21
+ }
22
+
23
+ interface DoctorSummary {
24
+ pass: number;
25
+ warn: number;
26
+ fail: number;
27
+ }
28
+
29
+ interface ToolResult {
30
+ name: string;
31
+ cli: string;
32
+ npm: string;
33
+ installed: boolean;
34
+ version?: string;
35
+ latest?: string;
36
+ upToDate?: boolean;
37
+ doctorSummary?: DoctorSummary;
38
+ latestError?: string;
39
+ }
40
+
41
+ async function getInstalledVersion(cli: string): Promise<string | null> {
42
+ // Try --version --json first
43
+ try {
44
+ const proc = Bun.spawn([cli, "--version", "--json"], {
45
+ stdout: "pipe",
46
+ stderr: "pipe",
47
+ });
48
+ const exitCode = await proc.exited;
49
+ if (exitCode === 0) {
50
+ const stdout = await new Response(proc.stdout).text();
51
+ try {
52
+ const data = JSON.parse(stdout.trim()) as { version?: string };
53
+ if (data.version) return data.version;
54
+ } catch {
55
+ // Not valid JSON, fall through to plain text
56
+ }
57
+ }
58
+ } catch {
59
+ // CLI not found — fall through to plain text fallback
60
+ }
61
+
62
+ // Fallback: --version plain text
63
+ try {
64
+ const proc = Bun.spawn([cli, "--version"], {
65
+ stdout: "pipe",
66
+ stderr: "pipe",
67
+ });
68
+ const exitCode = await proc.exited;
69
+ if (exitCode === 0) {
70
+ const stdout = await new Response(proc.stdout).text();
71
+ const match = stdout.match(/(\d+\.\d+\.\d+)/);
72
+ if (match?.[1]) return match[1];
73
+ }
74
+ } catch {
75
+ // CLI not found
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ async function fetchLatestVersion(packageName: string): Promise<string> {
82
+ const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
83
+ if (!res.ok) {
84
+ throw new Error(`npm registry error: ${res.status} ${res.statusText}`);
85
+ }
86
+ const data = (await res.json()) as { version: string };
87
+ return data.version;
88
+ }
89
+
90
+ async function getDoctorSummary(): Promise<DoctorSummary | undefined> {
91
+ try {
92
+ const proc = Bun.spawn(["ov", "doctor", "--json"], {
93
+ stdout: "pipe",
94
+ stderr: "pipe",
95
+ });
96
+ await proc.exited;
97
+ const stdout = await new Response(proc.stdout).text();
98
+ const trimmed = stdout.trim();
99
+ if (trimmed) {
100
+ const data = JSON.parse(trimmed) as {
101
+ summary?: { pass?: number; warn?: number; fail?: number };
102
+ };
103
+ if (data.summary) {
104
+ return {
105
+ pass: data.summary.pass ?? 0,
106
+ warn: data.summary.warn ?? 0,
107
+ fail: data.summary.fail ?? 0,
108
+ };
109
+ }
110
+ }
111
+ } catch {
112
+ // Doctor failed — report nothing
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ async function checkTool(tool: { name: string; cli: string; npm: string }): Promise<ToolResult> {
118
+ const version = await getInstalledVersion(tool.cli);
119
+
120
+ if (version === null) {
121
+ return { name: tool.name, cli: tool.cli, npm: tool.npm, installed: false };
122
+ }
123
+
124
+ let latest: string | undefined;
125
+ let latestError: string | undefined;
126
+ let doctorSummary: DoctorSummary | undefined;
127
+
128
+ const latestPromise = fetchLatestVersion(tool.npm)
129
+ .then((v) => {
130
+ latest = v;
131
+ })
132
+ .catch((err) => {
133
+ latestError = err instanceof Error ? err.message : String(err);
134
+ });
135
+
136
+ const doctorPromise =
137
+ tool.name === "overstory"
138
+ ? getDoctorSummary().then((d) => {
139
+ doctorSummary = d;
140
+ })
141
+ : Promise.resolve();
142
+
143
+ await Promise.all([latestPromise, doctorPromise]);
144
+
145
+ const upToDate = latest !== undefined ? version === latest : undefined;
146
+
147
+ return {
148
+ name: tool.name,
149
+ cli: tool.cli,
150
+ npm: tool.npm,
151
+ installed: true,
152
+ version,
153
+ latest,
154
+ upToDate,
155
+ doctorSummary,
156
+ latestError,
157
+ };
158
+ }
159
+
160
+ function formatDoctorLine(summary: DoctorSummary): string {
161
+ const parts: string[] = [];
162
+ if (summary.pass > 0) parts.push(color.green(`${summary.pass} passed`));
163
+ if (summary.warn > 0) parts.push(color.yellow(`${summary.warn} warn`));
164
+ if (summary.fail > 0) parts.push(color.red(`${summary.fail} fail`));
165
+ return parts.length > 0 ? parts.join(", ") : "no checks";
166
+ }
167
+
168
+ function printHumanOutput(results: ToolResult[]): void {
169
+ process.stdout.write(`${brand.bold("os-eco Ecosystem")}\n`);
170
+ process.stdout.write(`${"═".repeat(60)}\n`);
171
+ process.stdout.write("\n");
172
+
173
+ for (const tool of results) {
174
+ if (!tool.installed) {
175
+ process.stdout.write(
176
+ ` ${color.red("x")} ${accent(tool.name)} ${muted(`(${tool.cli})`)} ${color.red("not installed")}\n`,
177
+ );
178
+ process.stdout.write(` ${muted(`npm i -g ${tool.npm}`)}\n`);
179
+ process.stdout.write("\n");
180
+ continue;
181
+ }
182
+
183
+ // Determine status icon
184
+ let icon: string;
185
+ if (tool.latestError !== undefined || tool.upToDate === undefined) {
186
+ icon = muted("-");
187
+ } else if (tool.upToDate) {
188
+ icon = color.green("-");
189
+ } else {
190
+ icon = color.yellow("!");
191
+ }
192
+
193
+ process.stdout.write(` ${icon} ${accent(tool.name)} ${muted(`(${tool.cli})`)}\n`);
194
+
195
+ // Version line
196
+ let versionLine = `Version: ${tool.version}`;
197
+ if (tool.latestError !== undefined) {
198
+ versionLine += ` ${muted("(version check failed)")}`;
199
+ } else if (tool.upToDate === true) {
200
+ versionLine += ` ${color.green("(up to date)")}`;
201
+ } else if (tool.upToDate === false) {
202
+ versionLine += ` ${color.yellow(`(outdated, latest: ${tool.latest})`)}`;
203
+ }
204
+ process.stdout.write(` ${versionLine}\n`);
205
+
206
+ // Doctor summary (overstory only)
207
+ if (tool.doctorSummary !== undefined) {
208
+ process.stdout.write(` Doctor: ${formatDoctorLine(tool.doctorSummary)}\n`);
209
+ }
210
+
211
+ process.stdout.write("\n");
212
+ }
213
+
214
+ const installed = results.filter((t) => t.installed).length;
215
+ const missing = results.filter((t) => !t.installed).length;
216
+ const outdated = results.filter(
217
+ (t) => t.installed && t.upToDate === false && t.latestError === undefined,
218
+ ).length;
219
+
220
+ let summary = `Summary: ${installed}/${results.length} installed`;
221
+ if (missing > 0) summary += `, ${missing} missing`;
222
+ if (outdated > 0) summary += `, ${outdated} outdated`;
223
+ process.stdout.write(`${summary}\n`);
224
+ }
225
+
226
+ export async function executeEcosystem(opts: EcosystemOptions): Promise<void> {
227
+ const json = opts.json ?? false;
228
+
229
+ let results: ToolResult[];
230
+ try {
231
+ results = await Promise.all(TOOLS.map((tool) => checkTool(tool)));
232
+ } catch (err) {
233
+ const msg = err instanceof Error ? err.message : String(err);
234
+ if (json) {
235
+ jsonError("ecosystem", msg);
236
+ } else {
237
+ process.stderr.write(`Error: ${msg}\n`);
238
+ }
239
+ process.exitCode = 1;
240
+ return;
241
+ }
242
+
243
+ if (json) {
244
+ const installed = results.filter((t) => t.installed).length;
245
+ const missing = results.filter((t) => !t.installed).length;
246
+ const outdated = results.filter(
247
+ (t) => t.installed && t.upToDate === false && t.latestError === undefined,
248
+ ).length;
249
+
250
+ jsonOutput("ecosystem", {
251
+ tools: results.map((t) => {
252
+ const entry: Record<string, unknown> = {
253
+ name: t.name,
254
+ cli: t.cli,
255
+ npm: t.npm,
256
+ installed: t.installed,
257
+ };
258
+ if (t.installed) {
259
+ entry.version = t.version;
260
+ entry.latest = t.latest;
261
+ entry.upToDate = t.upToDate;
262
+ if (t.doctorSummary !== undefined) {
263
+ entry.doctorSummary = t.doctorSummary;
264
+ }
265
+ if (t.latestError !== undefined) {
266
+ entry.latestError = t.latestError;
267
+ }
268
+ }
269
+ return entry;
270
+ }),
271
+ summary: {
272
+ total: results.length,
273
+ installed,
274
+ missing,
275
+ outdated,
276
+ },
277
+ });
278
+ return;
279
+ }
280
+
281
+ printHumanOutput(results);
282
+ }
283
+
284
+ export function createEcosystemCommand(): Command {
285
+ return new Command("ecosystem")
286
+ .description("Show a summary dashboard of all installed os-eco tools")
287
+ .option("--json", "Output as JSON")
288
+ .action(async (opts: EcosystemOptions) => {
289
+ await executeEcosystem(opts);
290
+ });
291
+ }
@@ -264,7 +264,7 @@ async function executeFeed(opts: FeedOpts): Promise<void> {
264
264
  } else {
265
265
  // JSON mode: print each event as a line
266
266
  for (const event of initialEvents) {
267
- process.stdout.write(`${JSON.stringify(event)}\n`);
267
+ jsonOutput("feed", { event });
268
268
  }
269
269
  if (initialEvents.length > 0) {
270
270
  const lastEvent = initialEvents[initialEvents.length - 1];
@@ -308,7 +308,7 @@ async function executeFeed(opts: FeedOpts): Promise<void> {
308
308
  } else {
309
309
  // JSON mode: print each event as a line
310
310
  for (const event of newEvents) {
311
- process.stdout.write(`${JSON.stringify(event)}\n`);
311
+ jsonOutput("feed", { event });
312
312
  }
313
313
  }
314
314