macroclaw 0.3.0 → 0.5.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 +79 -73
- package/bin/macroclaw.js +3 -1
- package/package.json +4 -3
- package/src/claude.ts +1 -1
- package/src/cli.test.ts +293 -0
- package/src/cli.ts +187 -0
- package/src/index.ts +31 -41
- package/src/logger.test.ts +18 -7
- package/src/logger.ts +17 -7
- package/src/service.test.ts +590 -0
- package/src/service.ts +346 -0
- package/src/setup.test.ts +160 -3
- package/src/setup.ts +63 -8
package/src/cli.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {execSync} from "node:child_process";
|
|
2
|
+
import {existsSync, readFileSync} from "node:fs";
|
|
3
|
+
import {join, resolve} from "node:path";
|
|
4
|
+
import {createInterface} from "node:readline";
|
|
5
|
+
import {defineCommand} from "citty";
|
|
6
|
+
import pkg from "../package.json" with { type: "json" };
|
|
7
|
+
import {initLogger} from "./logger";
|
|
8
|
+
import {ServiceManager, type SystemService} from "./service";
|
|
9
|
+
import {loadSessions} from "./sessions";
|
|
10
|
+
import {loadSettings, saveSettings} from "./settings";
|
|
11
|
+
import {runSetupWizard} from "./setup";
|
|
12
|
+
|
|
13
|
+
export interface SetupDeps {
|
|
14
|
+
initLogger: () => Promise<void>;
|
|
15
|
+
saveSettings: (settings: unknown, dir: string) => void;
|
|
16
|
+
loadRawSettings: (dir: string) => Record<string, unknown> | null;
|
|
17
|
+
runSetupWizard: (io: { ask: (q: string) => Promise<string>; write: (m: string) => void; close?: () => void }, opts?: { defaults?: Record<string, unknown>; onSettingsReady?: (settings: unknown) => void }) => Promise<unknown>;
|
|
18
|
+
createReadlineInterface: () => { question: (q: string, cb: (a: string) => void) => void; close: () => void };
|
|
19
|
+
resolveDir: () => string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadRawSettings(dir: string): Record<string, unknown> | null {
|
|
23
|
+
const path = join(dir, "settings.json");
|
|
24
|
+
if (!existsSync(path)) return null;
|
|
25
|
+
try {
|
|
26
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
27
|
+
return typeof raw === "object" && raw !== null ? raw : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function defaultSetupDeps(): SetupDeps {
|
|
34
|
+
return {
|
|
35
|
+
initLogger,
|
|
36
|
+
saveSettings: saveSettings as (settings: unknown, dir: string) => void,
|
|
37
|
+
loadRawSettings,
|
|
38
|
+
runSetupWizard: runSetupWizard as SetupDeps["runSetupWizard"],
|
|
39
|
+
createReadlineInterface: () => createInterface({ input: process.stdin, output: process.stdout }),
|
|
40
|
+
resolveDir: () => resolve(process.env.HOME || "~", ".macroclaw"),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function defaultSystemService(): SystemService {
|
|
45
|
+
return new ServiceManager();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class Cli {
|
|
49
|
+
readonly #setupDeps: SetupDeps;
|
|
50
|
+
readonly #systemService: SystemService;
|
|
51
|
+
|
|
52
|
+
constructor(setupDeps?: Partial<SetupDeps>, systemService?: SystemService) {
|
|
53
|
+
this.#setupDeps = { ...defaultSetupDeps(), ...setupDeps };
|
|
54
|
+
this.#systemService = systemService ?? defaultSystemService();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async setup(): Promise<void> {
|
|
58
|
+
await this.#setupDeps.initLogger();
|
|
59
|
+
const rl = this.#setupDeps.createReadlineInterface();
|
|
60
|
+
const io = {
|
|
61
|
+
ask: (question: string): Promise<string> =>
|
|
62
|
+
new Promise((res) => rl.question(question, (answer: string) => res(answer.trim()))),
|
|
63
|
+
write: (msg: string) => process.stdout.write(msg),
|
|
64
|
+
close: () => rl.close(),
|
|
65
|
+
};
|
|
66
|
+
const dir = this.#setupDeps.resolveDir();
|
|
67
|
+
const defaults = this.#setupDeps.loadRawSettings(dir) ?? undefined;
|
|
68
|
+
const settings = await this.#setupDeps.runSetupWizard(io, {
|
|
69
|
+
defaults,
|
|
70
|
+
onSettingsReady: (s) => this.#setupDeps.saveSettings(s, dir),
|
|
71
|
+
});
|
|
72
|
+
this.#setupDeps.saveSettings(settings, dir);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
claude(exec: (cmd: string, opts: object) => void = (cmd, opts) => execSync(cmd, opts)): void {
|
|
76
|
+
const dir = this.#setupDeps.resolveDir();
|
|
77
|
+
const settings = loadSettings(dir);
|
|
78
|
+
if (!settings) throw new Error("Settings not found. Run `macroclaw setup` first.");
|
|
79
|
+
const sessions = loadSessions(dir);
|
|
80
|
+
const args = ["claude"];
|
|
81
|
+
if (sessions.mainSessionId) args.push("--resume", sessions.mainSessionId);
|
|
82
|
+
args.push("--model", settings.model);
|
|
83
|
+
exec(args.join(" "), { cwd: settings.workspace, stdio: "inherit", env: { ...process.env, CLAUDECODE: "" } });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
service(action: string, token?: string): void {
|
|
87
|
+
switch (action) {
|
|
88
|
+
case "install": {
|
|
89
|
+
const logCmd = this.#systemService.install(token);
|
|
90
|
+
console.log(`Service installed and started. Check logs:\n ${logCmd}`);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "uninstall":
|
|
94
|
+
this.#systemService.uninstall();
|
|
95
|
+
console.log("Service uninstalled.");
|
|
96
|
+
break;
|
|
97
|
+
case "start": {
|
|
98
|
+
const logCmd = this.#systemService.start();
|
|
99
|
+
console.log(`Service started. Check logs:\n ${logCmd}`);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case "stop":
|
|
103
|
+
this.#systemService.stop();
|
|
104
|
+
console.log("Service stopped.");
|
|
105
|
+
break;
|
|
106
|
+
case "update": {
|
|
107
|
+
const logCmd = this.#systemService.update();
|
|
108
|
+
console.log(`Service updated. Check logs:\n ${logCmd}`);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
default:
|
|
112
|
+
throw new Error(`Unknown service action: ${action}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function handleError(err: unknown): never {
|
|
118
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function runStart(): Promise<void> {
|
|
123
|
+
const { start } = await import("./index");
|
|
124
|
+
await start();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const defaultCli = new Cli();
|
|
128
|
+
|
|
129
|
+
const startCommand = defineCommand({
|
|
130
|
+
meta: { name: "start", description: "Start the macroclaw bridge" },
|
|
131
|
+
run: () => runStart().catch(handleError),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const setupCommand = defineCommand({
|
|
135
|
+
meta: { name: "setup", description: "Run the interactive setup wizard" },
|
|
136
|
+
run: () => defaultCli.setup().catch(handleError),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const claudeCommand = defineCommand({
|
|
140
|
+
meta: { name: "claude", description: "Open Claude Code CLI in the main session" },
|
|
141
|
+
run: () => { try { defaultCli.claude(); } catch (err) { handleError(err); } },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const serviceInstallCommand = defineCommand({
|
|
145
|
+
meta: { name: "install", description: "Install and start macroclaw as a system service" },
|
|
146
|
+
args: {
|
|
147
|
+
token: { type: "string", description: "Claude OAuth token from `claude setup-token` (required on macOS)" },
|
|
148
|
+
},
|
|
149
|
+
run: ({ args }) => { try { defaultCli.service("install", args.token); } catch (err) { handleError(err); } },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const serviceUninstallCommand = defineCommand({
|
|
153
|
+
meta: { name: "uninstall", description: "Stop and remove the system service" },
|
|
154
|
+
run: () => { try { defaultCli.service("uninstall"); } catch (err) { handleError(err); } },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const serviceStartCommand = defineCommand({
|
|
158
|
+
meta: { name: "start", description: "Start the system service" },
|
|
159
|
+
run: () => { try { defaultCli.service("start"); } catch (err) { handleError(err); } },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const serviceStopCommand = defineCommand({
|
|
163
|
+
meta: { name: "stop", description: "Stop the system service" },
|
|
164
|
+
run: () => { try { defaultCli.service("stop"); } catch (err) { handleError(err); } },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const serviceUpdateCommand = defineCommand({
|
|
168
|
+
meta: { name: "update", description: "Reinstall latest version and restart the service" },
|
|
169
|
+
run: () => { try { defaultCli.service("update"); } catch (err) { handleError(err); } },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const serviceCommand = defineCommand({
|
|
173
|
+
meta: { name: "service", description: "Manage macroclaw system service" },
|
|
174
|
+
subCommands: {
|
|
175
|
+
install: serviceInstallCommand,
|
|
176
|
+
uninstall: serviceUninstallCommand,
|
|
177
|
+
start: serviceStartCommand,
|
|
178
|
+
stop: serviceStopCommand,
|
|
179
|
+
update: serviceUpdateCommand,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export const main = defineCommand({
|
|
184
|
+
meta: { name: pkg.name, description: pkg.description, version: pkg.version },
|
|
185
|
+
subCommands: { start: startCommand, setup: setupCommand, claude: claudeCommand, service: serviceCommand },
|
|
186
|
+
});
|
|
187
|
+
|
package/src/index.ts
CHANGED
|
@@ -1,58 +1,48 @@
|
|
|
1
1
|
import { cpSync, existsSync, readdirSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { createInterface } from "node:readline";
|
|
4
3
|
import { App, type AppConfig } from "./app";
|
|
5
4
|
import { createLogger, initLogger } from "./logger";
|
|
6
|
-
import { applyEnvOverrides, loadSettings, printSettings
|
|
7
|
-
import { runSetupWizard } from "./setup";
|
|
5
|
+
import { applyEnvOverrides, loadSettings, printSettings } from "./settings";
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
const log = createLogger("index");
|
|
7
|
+
export async function start(): Promise<void> {
|
|
8
|
+
const log = createLogger("index");
|
|
11
9
|
|
|
12
|
-
const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
|
|
10
|
+
const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
const settings = loadSettings(defaultDir);
|
|
15
13
|
|
|
16
|
-
if (!settings) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
ask: (question: string): Promise<string> =>
|
|
21
|
-
new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim()))),
|
|
22
|
-
write: (msg: string) => process.stdout.write(msg),
|
|
23
|
-
};
|
|
24
|
-
settings = await runSetupWizard(io);
|
|
25
|
-
rl.close();
|
|
26
|
-
saveSettings(settings, defaultDir);
|
|
27
|
-
log.info("Settings saved");
|
|
28
|
-
}
|
|
14
|
+
if (!settings) {
|
|
15
|
+
log.error("No settings found. Run `macroclaw setup` first.");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
29
18
|
|
|
30
|
-
const { settings: resolved, overrides } = applyEnvOverrides(settings);
|
|
31
|
-
settings = resolved;
|
|
19
|
+
const { settings: resolved, overrides } = applyEnvOverrides(settings);
|
|
32
20
|
|
|
33
|
-
|
|
21
|
+
await initLogger({ level: resolved.logLevel, pinoramaUrl: resolved.pinoramaUrl });
|
|
22
|
+
printSettings(resolved, overrides);
|
|
34
23
|
|
|
35
|
-
const workspace = resolve(
|
|
24
|
+
const workspace = resolve(resolved.workspace.replace(/^~/, process.env.HOME || "~"));
|
|
36
25
|
|
|
37
|
-
function initWorkspace(workspace: string) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
26
|
+
function initWorkspace(workspace: string) {
|
|
27
|
+
const templateDir = join(dirname(import.meta.dir), "workspace-template");
|
|
28
|
+
const exists = existsSync(workspace);
|
|
29
|
+
const empty = exists && readdirSync(workspace).length === 0;
|
|
41
30
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
if (!exists || empty) {
|
|
32
|
+
log.info({ workspace }, "Initializing workspace from template");
|
|
33
|
+
cpSync(templateDir, workspace, { recursive: true });
|
|
34
|
+
log.info("Workspace initialized");
|
|
35
|
+
}
|
|
46
36
|
}
|
|
47
|
-
}
|
|
48
37
|
|
|
49
|
-
initWorkspace(workspace);
|
|
38
|
+
initWorkspace(workspace);
|
|
50
39
|
|
|
51
|
-
const config: AppConfig = {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
};
|
|
40
|
+
const config: AppConfig = {
|
|
41
|
+
botToken: resolved.botToken,
|
|
42
|
+
authorizedChatId: resolved.chatId,
|
|
43
|
+
workspace,
|
|
44
|
+
model: resolved.model,
|
|
45
|
+
};
|
|
57
46
|
|
|
58
|
-
new App(config).start();
|
|
47
|
+
new App(config).start();
|
|
48
|
+
}
|
package/src/logger.test.ts
CHANGED
|
@@ -17,17 +17,28 @@ describe("createLogger", () => {
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
describe("initLogger", () => {
|
|
20
|
-
it("
|
|
21
|
-
|
|
20
|
+
it("does nothing when called without opts", async () => {
|
|
21
|
+
mockPinoramaTransport.mockClear();
|
|
22
22
|
await initLogger();
|
|
23
|
-
expect(mockPinoramaTransport).
|
|
24
|
-
|
|
23
|
+
expect(mockPinoramaTransport).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("sets log level from opts", async () => {
|
|
27
|
+
const log = createLogger("opts-level");
|
|
28
|
+
await initLogger({ level: "warn" });
|
|
29
|
+
expect(log.level).toBe("warn");
|
|
30
|
+
await initLogger({ level: "info" }); // restore
|
|
25
31
|
});
|
|
26
32
|
|
|
27
|
-
it("
|
|
28
|
-
delete process.env.PINORAMA_URL;
|
|
33
|
+
it("adds pinorama transport from opts", async () => {
|
|
29
34
|
mockPinoramaTransport.mockClear();
|
|
30
|
-
await initLogger();
|
|
35
|
+
await initLogger({ pinoramaUrl: "http://example.com/pinorama" });
|
|
36
|
+
expect(mockPinoramaTransport).toHaveBeenCalledWith({ url: "http://example.com/pinorama" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not add duplicate pinorama transport on second call", async () => {
|
|
40
|
+
mockPinoramaTransport.mockClear();
|
|
41
|
+
await initLogger({ pinoramaUrl: "http://example.com/pinorama" });
|
|
31
42
|
expect(mockPinoramaTransport).not.toHaveBeenCalled();
|
|
32
43
|
});
|
|
33
44
|
});
|
package/src/logger.ts
CHANGED
|
@@ -6,20 +6,30 @@ const prettyStream = pretty({
|
|
|
6
6
|
messageFormat: "[{module}] {msg}",
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const defaultLevel = (process.env.LOG_LEVEL || "info") as pino.Level;
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// Streams accept all levels; logger.level is the sole gate
|
|
12
|
+
const streams: pino.StreamEntry[] = [{ level: "trace", stream: prettyStream }];
|
|
12
13
|
|
|
13
14
|
const logger = pino(
|
|
14
|
-
{ level:
|
|
15
|
+
{ level: defaultLevel },
|
|
15
16
|
pino.multistream(streams),
|
|
16
17
|
);
|
|
17
18
|
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
export interface LoggerOptions {
|
|
20
|
+
level?: pino.Level;
|
|
21
|
+
pinoramaUrl?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let pinoramaAdded = false;
|
|
25
|
+
|
|
26
|
+
export async function initLogger(opts?: LoggerOptions): Promise<void> {
|
|
27
|
+
if (opts?.level) logger.level = opts.level;
|
|
28
|
+
|
|
29
|
+
if (opts?.pinoramaUrl && !pinoramaAdded) {
|
|
21
30
|
const { default: pinoramaTransport } = await import("pinorama-transport");
|
|
22
|
-
streams.push({ level, stream: pinoramaTransport({ url: pinoramaUrl }) });
|
|
31
|
+
streams.push({ level: "trace", stream: pinoramaTransport({ url: opts.pinoramaUrl }) });
|
|
32
|
+
pinoramaAdded = true;
|
|
23
33
|
}
|
|
24
34
|
}
|
|
25
35
|
|