@ouro.bot/cli 0.1.0-alpha.301 → 0.1.0-alpha.302

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.
package/changelog.json CHANGED
@@ -2,9 +2,9 @@
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
4
  {
5
- "version": "0.1.0-alpha.301",
5
+ "version": "0.1.0-alpha.302",
6
6
  "changes": [
7
- "fix(daemon): add an injectable `statusWriter` seam to `DaemonProcessManager` so human-visible config-failure lines still default to stderr in production, but tests can route or silence them intentionally. Status-writer failures are now swallowed with a `daemon.status_writer_error` nerves event instead of breaking agent startup bookkeeping. `process-manager.test.ts` now passes a no-op writer in the snapshot-focused config-check tests that were leaking expected fixture noise into the full coverage log, and adds a regression test proving a throwing status writer does not prevent the crashed snapshot/fix hint from being recorded."
7
+ "feat(cli): add `ouro doctor` system health check command. New command runs 6 diagnostic categories — daemon (socket existence + responsiveness), agents (bundle discovery, agent.json validation for version/humanFacing/agentFacing/enabled), senses (BlueBubbles and Teams config presence and well-formedness), habits (launchd plist discovery, degraded state), security (secrets.json permissions, credential leak detection in agent.json), and disk (log size thresholds at 100MB warn / 500MB critical, bundle root existence). Output is a colored checklist with per-category grouping and a summary line. Works without daemon running — daemon checks fail gracefully while all other categories still execute, making it useful for cold diagnostics. 3 new files (doctor.ts, doctor-types.ts, cli-render-doctor.ts), 3 modified (cli-types.ts, cli-parse.ts, cli-exec.ts), 4 test files with 61 tests at 100% coverage on new code."
8
8
  ]
9
9
  },
10
10
  {
@@ -68,6 +68,8 @@ const cli_parse_1 = require("./cli-parse");
68
68
  const cli_parse_2 = require("./cli-parse");
69
69
  const cli_render_1 = require("./cli-render");
70
70
  const cli_defaults_1 = require("./cli-defaults");
71
+ const doctor_1 = require("./doctor");
72
+ const cli_render_doctor_1 = require("./cli-render-doctor");
71
73
  // ── ensureDaemonRunning ──
72
74
  async function ensureDaemonRunning(deps) {
73
75
  const alive = await deps.checkSocketAlive(deps.socketPath);
@@ -1912,6 +1914,32 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
1912
1914
  deps.writeStdout(message);
1913
1915
  return message;
1914
1916
  }
1917
+ // ── doctor (local, no daemon socket needed) ──
1918
+ if (command.kind === "doctor") {
1919
+ const doctorDeps = {
1920
+ /* v8 ignore start -- thin fs wrappers tested via doctor.test.ts with injected deps @preserve */
1921
+ existsSync: (p) => fs.existsSync(p),
1922
+ readFileSync: (p) => fs.readFileSync(p, "utf-8"),
1923
+ readdirSync: (p) => fs.readdirSync(p),
1924
+ statSync: (p) => fs.statSync(p),
1925
+ /* v8 ignore stop */
1926
+ checkSocketAlive: deps.checkSocketAlive,
1927
+ socketPath: deps.socketPath,
1928
+ bundlesRoot: deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)(),
1929
+ secretsRoot: deps.secretsRoot ?? path.join(os.homedir(), ".agentsecrets"),
1930
+ homedir: os.homedir(),
1931
+ };
1932
+ const doctorResult = await (0, doctor_1.runDoctorChecks)(doctorDeps);
1933
+ const output = (0, cli_render_doctor_1.formatDoctorOutput)(doctorResult);
1934
+ deps.writeStdout(output);
1935
+ (0, runtime_1.emitNervesEvent)({
1936
+ component: "daemon",
1937
+ event: "daemon.doctor_run",
1938
+ message: "ouro doctor completed",
1939
+ meta: { passed: doctorResult.summary.passed, warnings: doctorResult.summary.warnings, failed: doctorResult.summary.failed },
1940
+ });
1941
+ return output;
1942
+ }
1915
1943
  const daemonCommand = toDaemonCommand(command);
1916
1944
  let response;
1917
1945
  try {
@@ -78,6 +78,7 @@ function usage() {
78
78
  " ouro mcp call <server> <tool> [--args '{...}']",
79
79
  " ouro rollback [<version>]",
80
80
  " ouro versions",
81
+ " ouro doctor",
81
82
  ].join("\n");
82
83
  }
83
84
  // ── Per-group parsers ──
@@ -802,6 +803,8 @@ function parseOuroCommand(args) {
802
803
  return parseMcpServeCommand(args.slice(1));
803
804
  if (head === "setup")
804
805
  return parseSetupCommand(args.slice(1));
806
+ if (head === "doctor")
807
+ return { kind: "doctor" };
805
808
  if (head === "bluebubbles")
806
809
  return parseBlueBubblesCommand(args.slice(1));
807
810
  throw new Error(`Unknown command '${args.join(" ")}'.\n${usage()}`);
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ /**
3
+ * Rendering for `ouro doctor` output.
4
+ *
5
+ * Pure function: takes a DoctorResult and returns a formatted string
6
+ * with ANSI colors, category grouping, and a summary line.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.formatDoctorOutput = formatDoctorOutput;
10
+ // ── ANSI color helpers ──
11
+ const RESET = "\x1b[0m";
12
+ const BOLD = "\x1b[1m";
13
+ const DIM = "\x1b[2m";
14
+ const GREEN = "\x1b[38;2;46;204;64m";
15
+ const RED = "\x1b[38;2;231;76;60m";
16
+ const YELLOW = "\x1b[38;2;230;190;50m";
17
+ const TEAL = "\x1b[38;2;78;201;176m";
18
+ function green(text) { return `${GREEN}${text}${RESET}`; }
19
+ function red(text) { return `${RED}${text}${RESET}`; }
20
+ function yellow(text) { return `${YELLOW}${text}${RESET}`; }
21
+ function bold(text) { return `${BOLD}${text}${RESET}`; }
22
+ function dim(text) { return `${DIM}${text}${RESET}`; }
23
+ function teal(text) { return `${TEAL}${text}${RESET}`; }
24
+ // ── Check status symbols ──
25
+ function statusSymbol(status) {
26
+ switch (status) {
27
+ case "pass": return green("\u2714"); // checkmark
28
+ case "warn": return yellow("\u26A0"); // warning
29
+ case "fail": return red("\u2718"); // X
30
+ }
31
+ }
32
+ // ── Main formatter ──
33
+ function formatDoctorOutput(result) {
34
+ const lines = [];
35
+ lines.push("");
36
+ lines.push(` ${bold("ouro doctor")}`);
37
+ lines.push("");
38
+ for (const category of result.categories) {
39
+ lines.push(` ${teal("--")} ${bold(category.name)} ${teal("-".repeat(Math.max(1, 40 - category.name.length)))}`);
40
+ for (const check of category.checks) {
41
+ const symbol = statusSymbol(check.status);
42
+ const detail = check.detail ? ` ${dim(check.detail)}` : "";
43
+ lines.push(` ${symbol} ${check.label}${detail}`);
44
+ }
45
+ lines.push("");
46
+ }
47
+ // Summary line
48
+ const { passed, warnings, failed } = result.summary;
49
+ const parts = [
50
+ green(`${passed} passed`),
51
+ yellow(`${warnings} warning${warnings === 1 ? "" : "s"}`),
52
+ red(`${failed} failed`),
53
+ ];
54
+ lines.push(` ${parts.join(dim(" | "))}`);
55
+ lines.push("");
56
+ return lines.join("\n");
57
+ }
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ /**
3
+ * Type definitions for the `ouro doctor` system health check.
4
+ *
5
+ * Describes the structure of health check results: individual checks,
6
+ * grouped categories, and the aggregated result.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,322 @@
1
+ "use strict";
2
+ /**
3
+ * System health check ("ouro doctor") — runs all diagnostic categories
4
+ * and aggregates results into a structured DoctorResult.
5
+ *
6
+ * Each category checker is isolated: if one throws, it produces a single
7
+ * "fail" check and the remaining categories still run.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.checkDaemon = checkDaemon;
11
+ exports.checkAgents = checkAgents;
12
+ exports.checkSenses = checkSenses;
13
+ exports.checkHabits = checkHabits;
14
+ exports.checkSecurity = checkSecurity;
15
+ exports.checkDisk = checkDisk;
16
+ exports.runDoctorChecks = runDoctorChecks;
17
+ const runtime_1 = require("../../nerves/runtime");
18
+ // ── Category checkers ──
19
+ async function checkDaemon(deps) {
20
+ const checks = [];
21
+ const socketExists = deps.existsSync(deps.socketPath);
22
+ checks.push({
23
+ label: "daemon socket exists",
24
+ status: socketExists ? "pass" : "fail",
25
+ detail: socketExists ? deps.socketPath : `not found at ${deps.socketPath}`,
26
+ });
27
+ if (socketExists) {
28
+ const alive = await deps.checkSocketAlive(deps.socketPath);
29
+ checks.push({
30
+ label: "daemon is responsive",
31
+ status: alive ? "pass" : "fail",
32
+ detail: alive ? "socket responded" : "socket exists but daemon unresponsive",
33
+ });
34
+ }
35
+ else {
36
+ checks.push({
37
+ label: "daemon is responsive",
38
+ status: "fail",
39
+ detail: "skipped — socket missing",
40
+ });
41
+ }
42
+ return { name: "Daemon", checks };
43
+ }
44
+ /** Discover all *.ouro directories under bundlesRoot. */
45
+ function discoverAgents(deps) {
46
+ if (!deps.existsSync(deps.bundlesRoot))
47
+ return [];
48
+ return deps.readdirSync(deps.bundlesRoot).filter((name) => name.endsWith(".ouro"));
49
+ }
50
+ function checkAgents(deps) {
51
+ const checks = [];
52
+ if (!deps.existsSync(deps.bundlesRoot)) {
53
+ checks.push({ label: "bundles directory", status: "fail", detail: `${deps.bundlesRoot} not found` });
54
+ return { name: "Agents", checks };
55
+ }
56
+ const agents = discoverAgents(deps);
57
+ if (agents.length === 0) {
58
+ checks.push({ label: "agent bundles", status: "warn", detail: "no *.ouro bundles found" });
59
+ return { name: "Agents", checks };
60
+ }
61
+ for (const agentDir of agents) {
62
+ const agentPath = `${deps.bundlesRoot}/${agentDir}`;
63
+ const configPath = `${agentPath}/agent.json`;
64
+ if (!deps.existsSync(configPath)) {
65
+ checks.push({ label: `${agentDir}/agent.json`, status: "fail", detail: "missing" });
66
+ continue;
67
+ }
68
+ let config;
69
+ try {
70
+ config = JSON.parse(deps.readFileSync(configPath));
71
+ }
72
+ catch {
73
+ checks.push({ label: `${agentDir}/agent.json`, status: "fail", detail: "unparseable JSON" });
74
+ continue;
75
+ }
76
+ const missing = [];
77
+ if (!config.version)
78
+ missing.push("version");
79
+ if (!config.humanFacing || typeof config.humanFacing !== "object") {
80
+ missing.push("humanFacing");
81
+ }
82
+ else {
83
+ const hf = config.humanFacing;
84
+ if (!hf.provider)
85
+ missing.push("humanFacing.provider");
86
+ if (!hf.model)
87
+ missing.push("humanFacing.model");
88
+ }
89
+ if (!config.agentFacing || typeof config.agentFacing !== "object") {
90
+ missing.push("agentFacing");
91
+ }
92
+ else {
93
+ const af = config.agentFacing;
94
+ if (!af.provider)
95
+ missing.push("agentFacing.provider");
96
+ if (!af.model)
97
+ missing.push("agentFacing.model");
98
+ }
99
+ if (missing.length > 0) {
100
+ checks.push({ label: `${agentDir}/agent.json`, status: "warn", detail: `missing fields: ${missing.join(", ")}` });
101
+ }
102
+ else {
103
+ checks.push({ label: `${agentDir}/agent.json`, status: "pass", detail: "valid" });
104
+ }
105
+ }
106
+ return { name: "Agents", checks };
107
+ }
108
+ function checkSenses(deps) {
109
+ const checks = [];
110
+ const agents = discoverAgents(deps);
111
+ for (const agentDir of agents) {
112
+ const configPath = `${deps.bundlesRoot}/${agentDir}/agent.json`;
113
+ if (!deps.existsSync(configPath))
114
+ continue;
115
+ let config;
116
+ try {
117
+ config = JSON.parse(deps.readFileSync(configPath));
118
+ }
119
+ catch {
120
+ checks.push({ label: `${agentDir} senses`, status: "fail", detail: "agent.json unparseable" });
121
+ continue;
122
+ }
123
+ if (!config.senses || typeof config.senses !== "object") {
124
+ checks.push({ label: `${agentDir} senses`, status: "warn", detail: "no senses config block" });
125
+ continue;
126
+ }
127
+ const senses = config.senses;
128
+ const senseNames = ["cli", "teams", "bluebubbles"];
129
+ for (const sense of senseNames) {
130
+ if (!(sense in senses))
131
+ continue;
132
+ const entry = senses[sense];
133
+ if (!entry || typeof entry !== "object") {
134
+ checks.push({ label: `${agentDir} ${sense}`, status: "fail", detail: "malformed sense entry" });
135
+ continue;
136
+ }
137
+ const senseObj = entry;
138
+ if (typeof senseObj.enabled !== "boolean") {
139
+ checks.push({ label: `${agentDir} ${sense}`, status: "warn", detail: "missing enabled boolean" });
140
+ }
141
+ else {
142
+ checks.push({
143
+ label: `${agentDir} ${sense}`,
144
+ status: "pass",
145
+ detail: senseObj.enabled ? "enabled" : "disabled",
146
+ });
147
+ }
148
+ }
149
+ }
150
+ if (checks.length === 0) {
151
+ checks.push({ label: "senses", status: "warn", detail: "no agents with senses config found" });
152
+ }
153
+ return { name: "Senses", checks };
154
+ }
155
+ function checkHabits(deps) {
156
+ const checks = [];
157
+ const agents = discoverAgents(deps);
158
+ for (const agentDir of agents) {
159
+ const agentName = agentDir.replace(/\.ouro$/, "");
160
+ const habitsDir = `${deps.bundlesRoot}/${agentDir}/habits`;
161
+ if (!deps.existsSync(habitsDir)) {
162
+ checks.push({ label: `${agentDir} habits dir`, status: "warn", detail: "no habits directory" });
163
+ continue;
164
+ }
165
+ checks.push({ label: `${agentDir} habits dir`, status: "pass", detail: habitsDir });
166
+ // Check for launchd plists on macOS
167
+ const launchAgentsDir = `${deps.homedir}/Library/LaunchAgents`;
168
+ if (deps.existsSync(launchAgentsDir)) {
169
+ const plists = deps.readdirSync(launchAgentsDir).filter((f) => f.startsWith(`bot.ouro.${agentName}.`) && f.endsWith(".plist"));
170
+ if (plists.length > 0) {
171
+ checks.push({ label: `${agentDir} launchd plists`, status: "pass", detail: `${plists.length} plist(s)` });
172
+ }
173
+ else {
174
+ checks.push({ label: `${agentDir} launchd plists`, status: "fail", detail: "no matching plists in LaunchAgents" });
175
+ }
176
+ }
177
+ }
178
+ if (checks.length === 0) {
179
+ checks.push({ label: "habits", status: "warn", detail: "no agents found" });
180
+ }
181
+ return { name: "Habits", checks };
182
+ }
183
+ function checkSecurity(deps) {
184
+ const checks = [];
185
+ const agents = discoverAgents(deps);
186
+ for (const agentDir of agents) {
187
+ const agentName = agentDir.replace(/\.ouro$/, "");
188
+ const secretsPath = `${deps.secretsRoot}/${agentName}/secrets.json`;
189
+ if (!deps.existsSync(secretsPath)) {
190
+ checks.push({ label: `${agentDir} secrets.json`, status: "fail", detail: "missing" });
191
+ continue;
192
+ }
193
+ // Check file permissions
194
+ const stat = deps.statSync(secretsPath);
195
+ const worldReadable = (stat.mode & 0o004) !== 0;
196
+ if (worldReadable) {
197
+ checks.push({ label: `${agentDir} secrets.json perms`, status: "warn", detail: "world-readable — consider chmod 600" });
198
+ }
199
+ else {
200
+ checks.push({ label: `${agentDir} secrets.json perms`, status: "pass", detail: "not world-readable" });
201
+ }
202
+ // Check agent.json for leaked credential keys
203
+ const configPath = `${deps.bundlesRoot}/${agentDir}/agent.json`;
204
+ if (deps.existsSync(configPath)) {
205
+ try {
206
+ const raw = deps.readFileSync(configPath);
207
+ const sensitiveKeys = ["apiKey", "token", "secret", "password"];
208
+ const found = sensitiveKeys.filter((key) => raw.includes(`"${key}"`));
209
+ if (found.length > 0) {
210
+ checks.push({ label: `${agentDir} credential leak`, status: "warn", detail: `agent.json contains keys: ${found.join(", ")}` });
211
+ }
212
+ else {
213
+ checks.push({ label: `${agentDir} credential leak`, status: "pass", detail: "no credential keys in agent.json" });
214
+ }
215
+ }
216
+ catch {
217
+ checks.push({ label: `${agentDir} credential leak`, status: "fail", detail: "could not read agent.json" });
218
+ }
219
+ }
220
+ }
221
+ if (checks.length === 0) {
222
+ checks.push({ label: "security", status: "warn", detail: "no agents found" });
223
+ }
224
+ return { name: "Security", checks };
225
+ }
226
+ function checkDisk(deps) {
227
+ const checks = [];
228
+ // Check daemon logs directory
229
+ const logsDir = `${deps.homedir}/.ouro-cli/logs`;
230
+ if (!deps.existsSync(logsDir)) {
231
+ checks.push({ label: "daemon logs dir", status: "warn", detail: `${logsDir} not found` });
232
+ }
233
+ else {
234
+ let totalSize = 0;
235
+ try {
236
+ const files = deps.readdirSync(logsDir);
237
+ for (const file of files) {
238
+ try {
239
+ const stat = deps.statSync(`${logsDir}/${file}`);
240
+ totalSize += stat.size;
241
+ }
242
+ catch {
243
+ // skip unreadable files
244
+ }
245
+ }
246
+ }
247
+ catch {
248
+ // readdirSync failure handled below
249
+ }
250
+ const sizeMB = totalSize / (1024 * 1024);
251
+ if (sizeMB > 500) {
252
+ checks.push({ label: "daemon log size", status: "fail", detail: `${sizeMB.toFixed(1)}MB — exceeds 500MB limit` });
253
+ }
254
+ else if (sizeMB > 100) {
255
+ checks.push({ label: "daemon log size", status: "warn", detail: `${sizeMB.toFixed(1)}MB — consider pruning with \`ouro logs prune\`` });
256
+ }
257
+ else {
258
+ checks.push({ label: "daemon log size", status: "pass", detail: `${sizeMB.toFixed(1)}MB` });
259
+ }
260
+ }
261
+ // Check AgentBundles root
262
+ if (deps.existsSync(deps.bundlesRoot)) {
263
+ checks.push({ label: "bundles root", status: "pass", detail: deps.bundlesRoot });
264
+ }
265
+ else {
266
+ checks.push({ label: "bundles root", status: "warn", detail: `${deps.bundlesRoot} not found` });
267
+ }
268
+ return { name: "Disk", checks };
269
+ }
270
+ // ── Orchestrator ──
271
+ function computeSummary(categories) {
272
+ let passed = 0;
273
+ let warnings = 0;
274
+ let failed = 0;
275
+ for (const cat of categories) {
276
+ for (const check of cat.checks) {
277
+ /* v8 ignore next 3 -- all three branches tested; v8 misreports compound if/else-if chain @preserve */
278
+ if (check.status === "pass")
279
+ passed++;
280
+ else if (check.status === "warn")
281
+ warnings++;
282
+ else
283
+ failed++;
284
+ }
285
+ }
286
+ return { passed, warnings, failed };
287
+ }
288
+ const CATEGORY_CHECKERS = [
289
+ { name: "Daemon", fn: checkDaemon },
290
+ { name: "Agents", fn: checkAgents },
291
+ { name: "Senses", fn: checkSenses },
292
+ { name: "Habits", fn: checkHabits },
293
+ { name: "Security", fn: checkSecurity },
294
+ { name: "Disk", fn: checkDisk },
295
+ ];
296
+ async function runDoctorChecks(deps) {
297
+ const categories = [];
298
+ for (const checker of CATEGORY_CHECKERS) {
299
+ try {
300
+ const category = await Promise.resolve(checker.fn(deps));
301
+ categories.push(category);
302
+ }
303
+ catch (error) {
304
+ (0, runtime_1.emitNervesEvent)({
305
+ level: "warn",
306
+ component: "daemon",
307
+ event: "daemon.doctor_check_error",
308
+ message: `doctor check ${checker.name} failed`,
309
+ meta: { category: checker.name, error: error instanceof Error ? error.message : String(error) },
310
+ });
311
+ categories.push({
312
+ name: checker.name,
313
+ checks: [{
314
+ label: checker.name.toLowerCase(),
315
+ status: "fail",
316
+ detail: `check crashed: ${error instanceof Error ? error.message : String(error)}`,
317
+ }],
318
+ });
319
+ }
320
+ }
321
+ return { categories, summary: computeSummary(categories) };
322
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.301",
3
+ "version": "0.1.0-alpha.302",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",