@moor-sh/cli 0.2.0 → 0.3.0

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/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @moor-sh/cli
2
+
3
+ Command-line interface for [moor](https://github.com/caiopizzol/moor) - manage your moor server's projects, logs, env vars, and container lifecycle from a terminal. Ships a `moor` binary.
4
+
5
+ Requires [Bun](https://bun.sh) on the machine running the CLI.
6
+
7
+ ## Install
8
+
9
+ **One-shot** (no install):
10
+
11
+ ```bash
12
+ bunx @moor-sh/cli status
13
+ ```
14
+
15
+ **Global install** (puts `moor` on PATH):
16
+
17
+ ```bash
18
+ bun add -g @moor-sh/cli
19
+ moor status
20
+ ```
21
+
22
+ Don't use `bunx moor` (without the scope) - `moor` on npm is an unrelated package.
23
+
24
+ ## Configure
25
+
26
+ ```bash
27
+ export MOOR_URL=https://moor.example.com # or http://127.0.0.1:8080 via SSH tunnel
28
+ export MOOR_API_KEY=your-api-key
29
+ ```
30
+
31
+ `MOOR_API_KEY` grants admin-equivalent control of the moor host. See the [self-hosting guide](https://github.com/caiopizzol/moor/blob/main/docs/self-hosting.md#api-keys) for how to generate and rotate it.
32
+
33
+ For a remote moor with private admin (the default), open an SSH tunnel from your laptop before running CLI commands:
34
+
35
+ ```bash
36
+ ssh -L 8080:127.0.0.1:3000 your-server
37
+ export MOOR_URL=http://127.0.0.1:8080
38
+ ```
39
+
40
+ ## Commands
41
+
42
+ ```
43
+ moor status # list all projects
44
+ moor logs <project> [-f] [-n 100] # view container logs
45
+ moor rebuild <project> # rebuild from source
46
+ moor restart <project> # stop + start
47
+ moor exec <project> <command> # run a command in the container
48
+ moor env list <project> # list environment variables
49
+ moor env set <project> KEY=VALUE # set environment variables and restart
50
+ moor stats # server resource usage
51
+ moor mcp config --client <name> # generate MCP client config snippet
52
+ ```
53
+
54
+ ## `moor mcp config`
55
+
56
+ Generates a ready-to-paste config snippet for an MCP-compatible AI client. Removes the "open a doc, copy a JSON block, fill in the blanks" step from MCP setup.
57
+
58
+ ```bash
59
+ moor mcp config --client claude # or --client claude-code (alias)
60
+ moor mcp config --client codex
61
+ ```
62
+
63
+ Output is JSON for `claude` / `claude-code` and TOML for `codex`. Prints to stdout - redirect or paste into `~/.claude.json` or `~/.codex/config.toml`. Optional flags: `--url <url>` (default `http://127.0.0.1:8080`), `--api-key <key>` (else read from `MOOR_API_KEY` env, then cwd `.env`, then a placeholder).
64
+
65
+ See [`@moor-sh/mcp`](https://www.npmjs.com/package/@moor-sh/mcp) for the MCP server itself.
66
+
67
+ ## Links
68
+
69
+ - [moor repo](https://github.com/caiopizzol/moor) - main project, install instructions
70
+ - [Self-hosting guide](https://github.com/caiopizzol/moor/blob/main/docs/self-hosting.md) - first boot, API keys, admin domain, port model
71
+ - [`@moor-sh/mcp`](https://www.npmjs.com/package/@moor-sh/mcp) - MCP server for AI agent integration
72
+
73
+ ## License
74
+
75
+ MIT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Command-line interface for moor - manage your moor server's projects, logs, env vars, and container lifecycle.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,7 +8,7 @@
8
8
  "url": "https://github.com/caiopizzol/moor.git",
9
9
  "directory": "packages/cli"
10
10
  },
11
- "homepage": "https://github.com/caiopizzol/moor#cli",
11
+ "homepage": "https://github.com/caiopizzol/moor/tree/main/packages/cli",
12
12
  "bugs": "https://github.com/caiopizzol/moor/issues",
13
13
  "keywords": [
14
14
  "moor",
@@ -0,0 +1,37 @@
1
+ // #133: regression test for the history arg parser. The original took the
2
+ // first non-flag token as the project, so `--hours 1 fipe-pg` silently used
3
+ // the hours value ("1") as the project. Parsing must be order-independent.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { parseHistoryArgs } from "./history";
7
+
8
+ describe("parseHistoryArgs", () => {
9
+ test("project then flag", () => {
10
+ expect(parseHistoryArgs(["fipe-pg", "--hours", "1"])).toEqual({ project: "fipe-pg", hours: 1 });
11
+ });
12
+
13
+ test("flag then project (regression: was mis-parsed as project '1')", () => {
14
+ expect(parseHistoryArgs(["--hours", "1", "fipe-pg"])).toEqual({ project: "fipe-pg", hours: 1 });
15
+ });
16
+
17
+ test("--hours=N form", () => {
18
+ expect(parseHistoryArgs(["--hours=6", "fipe-pg"])).toEqual({ project: "fipe-pg", hours: 6 });
19
+ });
20
+
21
+ test("default hours is 24 when omitted", () => {
22
+ expect(parseHistoryArgs(["fipe-pg"])).toEqual({ project: "fipe-pg", hours: 24 });
23
+ });
24
+
25
+ test("missing project → error, no project", () => {
26
+ const r = parseHistoryArgs(["--hours", "1"]);
27
+ expect(r.project).toBeUndefined();
28
+ expect(r.error).toBeTruthy();
29
+ });
30
+
31
+ test("invalid hours → error", () => {
32
+ expect(parseHistoryArgs(["fipe-pg", "--hours", "0"]).error).toBeTruthy();
33
+ expect(parseHistoryArgs(["fipe-pg", "--hours", "-3"]).error).toBeTruthy();
34
+ expect(parseHistoryArgs(["fipe-pg", "--hours", "abc"]).error).toBeTruthy();
35
+ expect(parseHistoryArgs(["fipe-pg", "--hours"]).error).toBeTruthy(); // no value
36
+ });
37
+ });
@@ -0,0 +1,117 @@
1
+ import { apiGet, resolveProject } from "../client";
2
+
3
+ type HistoryResponse = {
4
+ from_ms: number;
5
+ to_ms: number;
6
+ events: Array<{ occurred_at_ms: number; source: string; action: string }>;
7
+ summary: {
8
+ sample_count: number;
9
+ running_sample_count: number;
10
+ cpu_percent_avg: number | null;
11
+ cpu_percent_max: number | null;
12
+ mem_bytes_max: number | null;
13
+ net_rx_bytes_total: number;
14
+ net_tx_bytes_total: number;
15
+ event_counts: Record<string, number>;
16
+ has_gap: boolean;
17
+ };
18
+ };
19
+
20
+ function formatBytes(bytes: number): string {
21
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
22
+ const units = ["B", "KB", "MB", "GB", "TB"];
23
+ const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
24
+ const val = bytes / 1024 ** i;
25
+ return `${val.toFixed(val < 10 ? 1 : 0)} ${units[i]}`;
26
+ }
27
+
28
+ // Parse `<project> [--hours N]` in any order. Critically, `--hours`'s value is
29
+ // consumed as the flag's argument, not mistaken for the project — so both
30
+ // `history p --hours 1` and `history --hours 1 p` resolve `p` (the old
31
+ // first-non-flag-token approach silently took the hours value as the project).
32
+ // Also accepts `--hours=N`. Pure + exported for tests.
33
+ export function parseHistoryArgs(args: string[]): {
34
+ project?: string;
35
+ hours: number;
36
+ error?: string;
37
+ } {
38
+ let project: string | undefined;
39
+ let hours = 24;
40
+ for (let i = 0; i < args.length; i++) {
41
+ const a = args[i];
42
+ if (a === "--hours") {
43
+ const n = Number(args[++i]); // consume the next token as the value
44
+ if (!Number.isFinite(n) || n <= 0)
45
+ return { hours, error: "--hours must be a positive number" };
46
+ hours = n;
47
+ } else if (a.startsWith("--hours=")) {
48
+ const n = Number(a.slice("--hours=".length));
49
+ if (!Number.isFinite(n) || n <= 0)
50
+ return { hours, error: "--hours must be a positive number" };
51
+ hours = n;
52
+ } else if (!a.startsWith("-") && project === undefined) {
53
+ project = a;
54
+ }
55
+ // other flags / extra positionals are ignored
56
+ }
57
+ if (!project) return { hours, error: "missing project" };
58
+ return { project, hours };
59
+ }
60
+
61
+ // Stored resource history + lifecycle events for one project (not live — use
62
+ // `moor stats` for the host snapshot). Mirrors the moor_project_history MCP
63
+ // tool: a window summary plus recent events.
64
+ export async function historyCommand(args: string[]) {
65
+ const parsed = parseHistoryArgs(args);
66
+ if (parsed.error || !parsed.project) {
67
+ console.error(
68
+ parsed.error && parsed.error !== "missing project"
69
+ ? parsed.error
70
+ : "Usage: moor history <project> [--hours N]",
71
+ );
72
+ process.exit(1);
73
+ }
74
+
75
+ const project = await resolveProject(parsed.project);
76
+ const to = Date.now();
77
+ const from = to - parsed.hours * 3_600_000;
78
+ const res = await apiGet(`/api/projects/${project.id}/stats/history?from=${from}&to=${to}`);
79
+ if (!res.ok) {
80
+ console.error(`Failed to get history: ${res.status} ${await res.text()}`);
81
+ process.exit(1);
82
+ }
83
+
84
+ const h = (await res.json()) as HistoryResponse;
85
+ const s = h.summary;
86
+ const windowH = Math.round(((h.to_ms - h.from_ms) / 3_600_000) * 10) / 10;
87
+
88
+ console.log(`${project.name} — history over ~${windowH}h`);
89
+ if (s.has_gap) {
90
+ console.log(" \x1b[33m⚠ event gap recorded: events may be incomplete\x1b[0m");
91
+ }
92
+ console.log(` Samples: ${s.sample_count} total, ${s.running_sample_count} running`);
93
+ console.log(
94
+ ` CPU: avg ${s.cpu_percent_avg ?? "n/a"}% / max ${s.cpu_percent_max ?? "n/a"}%`,
95
+ );
96
+ console.log(
97
+ ` Memory: max ${s.mem_bytes_max !== null ? formatBytes(s.mem_bytes_max) : "n/a"}`,
98
+ );
99
+ console.log(
100
+ ` Network: in ${formatBytes(s.net_rx_bytes_total)} / out ${formatBytes(s.net_tx_bytes_total)}`,
101
+ );
102
+ const counts = Object.entries(s.event_counts);
103
+ if (counts.length > 0) {
104
+ console.log(` Events: ${counts.map(([a, n]) => `${a} ${n}`).join(", ")}`);
105
+ }
106
+
107
+ const recent = h.events.slice(-10);
108
+ if (recent.length > 0) {
109
+ console.log("\nRecent events:");
110
+ for (const e of recent) {
111
+ console.log(` ${new Date(e.occurred_at_ms).toISOString()} ${e.action} (${e.source})`);
112
+ }
113
+ }
114
+ if (s.sample_count === 0 && h.events.length === 0) {
115
+ console.log(" (no stored history in this window)");
116
+ }
117
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { envCommand } from "./commands/env";
5
5
  import { execCommand } from "./commands/exec";
6
+ import { historyCommand } from "./commands/history";
6
7
  import { logsCommand } from "./commands/logs";
7
8
  import { mcpCommand } from "./commands/mcp";
8
9
  import { rebuildCommand } from "./commands/rebuild";
@@ -34,6 +35,7 @@ Commands:
34
35
  env list <project> List environment variables
35
36
  env set <project> K=V [K=V ...] Set environment variables
36
37
  stats Show server resource usage
38
+ history <project> [--hours N] Stored resource history + events (default 24h)
37
39
  mcp config --client <name> Generate MCP client config snippet
38
40
 
39
41
  Environment:
@@ -66,6 +68,9 @@ switch (command) {
66
68
  case "stats":
67
69
  await statsCommand();
68
70
  break;
71
+ case "history":
72
+ await historyCommand(args.slice(1));
73
+ break;
69
74
  case "mcp":
70
75
  mcpCommand(args.slice(1));
71
76
  break;