lazyclaw 5.0.1 → 5.0.2

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/mcp/client.mjs ADDED
@@ -0,0 +1,87 @@
1
+ // MCP client — spawn external MCP servers over stdio, list their tools,
2
+ // and register each one in mas/tools/registry.mjs with a stable prefix
3
+ // ("mcp:<server>:<tool>"). Defaults: sensitive=true (so the approve hook
4
+ // gates every external call), category="mcp:<server>". Per-server
5
+ // allowGlob narrows which tools are exposed.
6
+ //
7
+ // The real transport uses @modelcontextprotocol/sdk's StdioClientTransport;
8
+ // tests inject a fake transport via __setTransport so the spec can run
9
+ // without the binary.
10
+
11
+ import * as registry from '../mas/tools/registry.mjs';
12
+
13
+ let _transport = null;
14
+ export function __setTransport(t) { _transport = t; }
15
+
16
+ const SERVERS = new Map(); // name -> { client, tools }
17
+
18
+ function matchGlob(name, glob) {
19
+ if (!glob || glob === '*') return true;
20
+ const re = new RegExp('^' + glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
21
+ return re.test(name);
22
+ }
23
+
24
+ async function realTransport({ command, args, env }) {
25
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
26
+ const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
27
+ const transport = new StdioClientTransport({ command, args: args || [], env });
28
+ const client = new Client({ name: 'lazyclaw', version: '5.0.0' }, { capabilities: {} });
29
+ await client.connect(transport);
30
+ return {
31
+ listTools: () => client.listTools(),
32
+ callTool: (req) => client.callTool(req),
33
+ close: () => client.close(),
34
+ };
35
+ }
36
+
37
+ export async function startServer({ name, command, args = [], env = {}, allowGlob = '*', sensitive = true } = {}) {
38
+ if (!name || !command) throw new Error('startServer: name + command required');
39
+ if (SERVERS.has(name)) throw new Error(`startServer: server "${name}" already running`);
40
+
41
+ const transport = _transport || { connect: realTransport };
42
+ const client = await transport.connect({ command, args, env });
43
+ const { tools = [] } = await client.listTools();
44
+
45
+ const registered = [];
46
+ for (const t of tools) {
47
+ if (!matchGlob(t.name, allowGlob)) continue;
48
+ const toolName = `mcp:${name}:${t.name}`;
49
+ const rec = {
50
+ name: toolName,
51
+ category: `mcp:${name}`,
52
+ sensitive,
53
+ description: t.description || `MCP tool ${t.name} on server ${name}`,
54
+ parameters: t.inputSchema || { type: 'object', properties: {} },
55
+ async exec(callArgs) {
56
+ try {
57
+ const res = await client.callTool({ name: t.name, arguments: callArgs || {} });
58
+ const text = (res?.content || [])
59
+ .filter(c => c.type === 'text')
60
+ .map(c => c.text)
61
+ .join('\n');
62
+ return { ok: true, text, raw: res };
63
+ } catch (e) {
64
+ return { ok: false, error: `${toolName}: ${e.message}` };
65
+ }
66
+ },
67
+ };
68
+ registry.register(rec);
69
+ registered.push(toolName);
70
+ }
71
+
72
+ SERVERS.set(name, { client, tools: registered });
73
+ return { ok: true, name, tools: registered };
74
+ }
75
+
76
+ export async function stopServer(name) {
77
+ const entry = SERVERS.get(name);
78
+ if (!entry) return { ok: false, error: `stopServer: ${name} not running` };
79
+ for (const toolName of entry.tools) registry.unregister(toolName);
80
+ try { await entry.client.close(); } catch { /* best-effort */ }
81
+ SERVERS.delete(name);
82
+ return { ok: true, name };
83
+ }
84
+
85
+ export function listServers() {
86
+ return [...SERVERS.entries()].map(([name, e]) => ({ name, toolCount: e.tools.length }));
87
+ }
@@ -0,0 +1,25 @@
1
+ // server_spawn — drive startServer from cfg.mcp.servers[] at daemon boot.
2
+ //
3
+ // cfg.mcp.servers = [
4
+ // { name: 'fs', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'],
5
+ // allowGlob: 'read_*' },
6
+ // { name: 'git', command: 'mcp-git', args: [] },
7
+ // ]
8
+
9
+ import { startServer, stopServer, listServers } from './client.mjs';
10
+
11
+ export async function startConfigured(cfg) {
12
+ const servers = cfg?.mcp?.servers || [];
13
+ const results = [];
14
+ for (const s of servers) {
15
+ try { results.push(await startServer(s)); }
16
+ catch (e) { results.push({ ok: false, name: s.name, error: e.message }); }
17
+ }
18
+ return results;
19
+ }
20
+
21
+ export async function stopAll() {
22
+ for (const { name } of listServers()) {
23
+ try { await stopServer(name); } catch { /* best-effort */ }
24
+ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -67,6 +67,8 @@
67
67
  "workflow/",
68
68
  "web/",
69
69
  "mas/",
70
+ "mcp/",
71
+ "tui/",
70
72
  "gateway/",
71
73
  "docs/multi-agent.md",
72
74
  "scripts/loop-worker.mjs",
@@ -0,0 +1,18 @@
1
+ // AUTO-GENERATED placeholder — replace by running `npm run build:splash`.
2
+ export const banner = {
3
+ rows: [
4
+ " ⣀⣠⣤⣶⣶⣦⣄⡀ ",
5
+ " ⢠⣾⠟⠉ ⠈⠙⢿⣦ ",
6
+ " ⣿⠁ ● ● ⣿ ",
7
+ " ⣿⡀ ⠈⠉⠉⠁ ⣿ ",
8
+ " ⠘⢿⣦⡀ ⢀⣴⡿⠃ ",
9
+ " ⠈⠙⠻⠷⠶⠶⠿⠟⠋ ",
10
+ " ⢸⠁ ⢸ ",
11
+ " ⢸⠉ ⠉⠉⠉⠉ ",
12
+ " lazyclaw ",
13
+ " "
14
+ ],
15
+ width: 24,
16
+ height: 10,
17
+ fg: "#FFB347"
18
+ };
package/tui/editor.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // tui/editor.mjs — multiline input state machine (spec §5.8).
2
+ //
3
+ // Pure-functional core (makeEditorState, applyKey) so it is testable
4
+ // without ink stdin. The React component <Editor/> wraps useInput().
5
+ import React, { useState, useEffect } from 'react';
6
+ import { Box, Text, useInput } from 'ink';
7
+ import { theme } from './theme.mjs';
8
+
9
+ export function makeEditorState({ history = [] } = {}) {
10
+ return {
11
+ buffer: '',
12
+ cursor: 0,
13
+ historyIdx: history.length,
14
+ history,
15
+ lastSubmit: null,
16
+ lastWasPaste: false,
17
+ };
18
+ }
19
+
20
+ export function applyKey(state, evt) {
21
+ const { input = '', key = {}, paste = false } = evt;
22
+ const next = { ...state, lastSubmit: null, lastWasPaste: false };
23
+
24
+ if (key.return && key.shift) {
25
+ next.buffer = state.buffer + '\n';
26
+ next.cursor = next.buffer.length;
27
+ return next;
28
+ }
29
+ if (key.return) {
30
+ next.lastSubmit = state.buffer;
31
+ next.buffer = '';
32
+ next.cursor = 0;
33
+ next.historyIdx = state.history.length;
34
+ return next;
35
+ }
36
+ if (key.upArrow) {
37
+ const idx = Math.max(0, state.historyIdx - 1);
38
+ if (state.history[idx] !== undefined) {
39
+ next.historyIdx = idx;
40
+ next.buffer = state.history[idx];
41
+ next.cursor = next.buffer.length;
42
+ }
43
+ return next;
44
+ }
45
+ if (key.downArrow) {
46
+ const idx = Math.min(state.history.length, state.historyIdx + 1);
47
+ next.historyIdx = idx;
48
+ next.buffer = state.history[idx] !== undefined ? state.history[idx] : '';
49
+ next.cursor = next.buffer.length;
50
+ return next;
51
+ }
52
+ if (key.backspace || key.delete) {
53
+ next.buffer = state.buffer.slice(0, -1);
54
+ next.cursor = next.buffer.length;
55
+ return next;
56
+ }
57
+ if (input) {
58
+ next.buffer = state.buffer + input;
59
+ next.cursor = next.buffer.length;
60
+ next.lastWasPaste = paste || input.length >= 16;
61
+ return next;
62
+ }
63
+ return next;
64
+ }
65
+
66
+ export function Editor({ history, onSubmit }) {
67
+ const [state, setState] = useState(() => makeEditorState({ history }));
68
+ useInput((input, key) => {
69
+ const next = applyKey(state, { input, key });
70
+ setState(next);
71
+ });
72
+ useEffect(() => {
73
+ if (state.lastSubmit !== null && onSubmit) onSubmit(state.lastSubmit);
74
+ }, [state.lastSubmit]);
75
+
76
+ const lines = state.buffer.split('\n');
77
+ return React.createElement(
78
+ Box,
79
+ { flexDirection: 'column' },
80
+ lines.map((ln, i) => React.createElement(Text, { key: i }, i === 0 ? theme.accent('› ') + ln : ' ' + ln))
81
+ );
82
+ }
package/tui/ghost.mjs ADDED
@@ -0,0 +1,42 @@
1
+ // tui/ghost.mjs — ink port of the v4 readline ghost autocomplete
2
+ // (cli.mjs:1388-1500). Pure functions; the React surface is owned
3
+ // by tui/repl.mjs which renders the suggestion dim-styled.
4
+ import { theme } from './theme.mjs';
5
+
6
+ function matches(prefix, cmds) {
7
+ return cmds.filter((c) => c.startsWith(prefix) && c !== prefix);
8
+ }
9
+
10
+ export function computeGhost(buffer, cmds) {
11
+ if (!buffer.startsWith('/')) return null;
12
+ const ms = matches(buffer, cmds);
13
+ if (ms.length === 0) return null;
14
+ const suggestion = ms[0];
15
+ return {
16
+ suggestion,
17
+ suffix: suggestion.slice(buffer.length),
18
+ candidates: ms,
19
+ idx: 0,
20
+ };
21
+ }
22
+
23
+ export function cycleGhost(ghost, cmds) {
24
+ if (!ghost || !ghost.candidates || ghost.candidates.length === 0) return ghost;
25
+ const next = (ghost.idx + 1) % ghost.candidates.length;
26
+ const suggestion = ghost.candidates[next];
27
+ return {
28
+ suggestion,
29
+ suffix: suggestion.slice(suggestion.length - (suggestion.length - ghost.candidates[ghost.idx].length + ghost.suffix.length)),
30
+ candidates: ghost.candidates,
31
+ idx: next,
32
+ };
33
+ }
34
+
35
+ export function acceptGhost(buffer, ghost) {
36
+ if (!ghost) return buffer;
37
+ return ghost.suggestion;
38
+ }
39
+
40
+ export function ghostStyle(suffix) {
41
+ return theme.dim(suffix);
42
+ }
package/tui/repl.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // tui/repl.mjs — REPL host with mid-stream interrupt-and-redirect
2
+ // (spec §5.8). Pure state functions for testability; the React
3
+ // mount lives at the bottom and is exercised only when stdin isTTY.
4
+ import React, { useState, useEffect } from 'react';
5
+ import { Box, useApp } from 'ink';
6
+ import { Splash } from './splash.mjs';
7
+ import { Editor } from './editor.mjs';
8
+
9
+ export function makeReplState() {
10
+ return {
11
+ streaming: false,
12
+ controller: null,
13
+ pendingPrepend: null,
14
+ nextTurnFirstMessage: null,
15
+ history: [],
16
+ };
17
+ }
18
+
19
+ export function onUserInput(state, { text, controller }) {
20
+ if (state.streaming && state.controller) {
21
+ // mid-stream interrupt — abort current turn, queue text for next turn.
22
+ try { state.controller.abort(); } catch {}
23
+ return { ...state, pendingPrepend: text };
24
+ }
25
+ // idle — start a new turn.
26
+ return {
27
+ ...state,
28
+ streaming: true,
29
+ controller,
30
+ history: [...state.history, text],
31
+ };
32
+ }
33
+
34
+ export function onEscape(state) {
35
+ if (state.streaming && state.controller) {
36
+ try { state.controller.abort(); } catch {}
37
+ }
38
+ return { ...state, streaming: false, controller: null, pendingPrepend: null };
39
+ }
40
+
41
+ export function onTurnComplete(state, { reason } = {}) {
42
+ void reason;
43
+ const promoted = state.pendingPrepend;
44
+ return {
45
+ ...state,
46
+ streaming: false,
47
+ controller: null,
48
+ pendingPrepend: null,
49
+ nextTurnFirstMessage: promoted,
50
+ };
51
+ }
52
+
53
+ export function consumeNextTurnFirstMessage(state) {
54
+ const msg = state.nextTurnFirstMessage;
55
+ return [{ ...state, nextTurnFirstMessage: null }, msg];
56
+ }
57
+
58
+ // ─── React mount ─────────────────────────────────────────────────────────
59
+ export function ReplApp({ splashProps, runTurn }) {
60
+ const [state, setState] = useState(makeReplState);
61
+ const { exit } = useApp();
62
+
63
+ async function handleSubmit(text) {
64
+ if (text === '/exit') { exit(); return; }
65
+ const controller = new AbortController();
66
+ setState((s) => onUserInput(s, { text, controller }));
67
+ try {
68
+ await runTurn(text, controller.signal);
69
+ setState((s) => onTurnComplete(s, { reason: 'done' }));
70
+ } catch (err) {
71
+ setState((s) => onTurnComplete(s, { reason: err.name === 'AbortError' ? 'aborted' : 'error' }));
72
+ }
73
+ }
74
+
75
+ useEffect(() => {
76
+ const [next, msg] = consumeNextTurnFirstMessage(state);
77
+ if (msg) {
78
+ setState(next);
79
+ handleSubmit(msg);
80
+ }
81
+ }, [state.nextTurnFirstMessage]);
82
+
83
+ return React.createElement(
84
+ Box,
85
+ { flexDirection: 'column' },
86
+ React.createElement(Splash, splashProps),
87
+ React.createElement(Editor, { history: state.history, onSubmit: handleSubmit })
88
+ );
89
+ }
package/tui/splash.mjs ADDED
@@ -0,0 +1,107 @@
1
+ // tui/splash.mjs — two-column launch splash (spec §5.1).
2
+ //
3
+ // Public surface:
4
+ // - <Splash {...props} /> ink component for live REPL mount
5
+ // - renderSplashToString(props) pure string builder used by tests
6
+ // and by the non-TTY path.
7
+ //
8
+ // Layout: 24-cell sloth gutter (cols 0-23) | 2-cell separator (24-25)
9
+ // | 52-cell right column (cols 26-77) | 2-cell right padding.
10
+ // Footer: exactly 4 lines, blank row separates body from footer.
11
+ import React from 'react';
12
+ import { Box, Text } from 'ink';
13
+ import stringWidth from 'string-width';
14
+ import { theme } from './theme.mjs';
15
+ import { banner } from './banner.generated.mjs';
16
+
17
+ const RIGHT_COL_WIDTH = 52;
18
+ const GUTTER_WIDTH = 24;
19
+
20
+ function fit(text, max) {
21
+ if (stringWidth(text) <= max) return text.padEnd(max);
22
+ let lo = 0, hi = text.length;
23
+ while (lo < hi) {
24
+ const mid = (lo + hi + 1) >> 1;
25
+ if (stringWidth(text.slice(0, mid) + '…') <= max) lo = mid;
26
+ else hi = mid - 1;
27
+ }
28
+ return (text.slice(0, lo) + '…').padEnd(max);
29
+ }
30
+
31
+ function shortCwd(cwd) {
32
+ const home = process.env.HOME || '';
33
+ if (home && cwd.startsWith(home)) return '~' + cwd.slice(home.length);
34
+ return cwd;
35
+ }
36
+
37
+ function toolRow({ category, sensitive, verbs }) {
38
+ const label = sensitive ? `(sensitive) ${category}` : category;
39
+ const labelWidth = sensitive ? 20 : 9;
40
+ const tail = verbs.slice(0, 6).join(' · ');
41
+ const more = verbs.length > 6 ? ` (${verbs.length - 6} more)` : '';
42
+ return `${fit(label, labelWidth)}${tail}${more}`;
43
+ }
44
+
45
+ function skillRow({ group, names }) {
46
+ const tail = names.slice(0, 6).join(' · ');
47
+ const more = names.length > 6 ? ` (${names.length - 6} more)` : '';
48
+ return `${fit(group, 9)}${tail}${more}`;
49
+ }
50
+
51
+ function buildBody(props) {
52
+ const { tools = [], skills = [] } = props;
53
+ const right = [];
54
+ right.push('Available Tools');
55
+ right.push('─'.repeat(45));
56
+ for (const t of tools.slice(0, 8)) right.push(toolRow(t));
57
+ if (tools.length > 8) right.push(`... and ${tools.length - 8} more tool groups`);
58
+ right.push('');
59
+ right.push('Available Skills');
60
+ right.push('─'.repeat(45));
61
+ for (const s of skills.slice(0, 8)) right.push(skillRow(s));
62
+ if (skills.length > 8) right.push(`... and ${skills.length - 8} more skill groups`);
63
+
64
+ const left = banner.rows.slice();
65
+ while (left.length < right.length) left.push('');
66
+ while (right.length < left.length) right.push('');
67
+
68
+ const lines = [];
69
+ for (let i = 0; i < left.length; i++) {
70
+ const lhs = fit(left[i], GUTTER_WIDTH);
71
+ const rhs = fit(right[i], RIGHT_COL_WIDTH);
72
+ lines.push(` ${lhs} ${rhs}`);
73
+ }
74
+ return lines;
75
+ }
76
+
77
+ function buildFooter(props) {
78
+ const { provider, model, trainer = {}, sessionId, cwd } = props;
79
+ const tProv = trainer.provider || provider;
80
+ const tModel = trainer.model || model;
81
+ const sid = (sessionId || '').slice(0, 8);
82
+ const cwdShort = shortCwd(cwd || process.cwd());
83
+ return [
84
+ fit(` provider · ${provider} · ${model}`, 56) + fit(`cwd · ${cwdShort}`, 22),
85
+ fit(` trainer · ${tProv} · ${tModel} session ${sid}`, 78),
86
+ fit(' slash · /help · /model · /trainer · /skills · /tools · /exit', 78),
87
+ fit(' hint · Shift+Enter newline · Ctrl-R recall · Esc interrupt', 78),
88
+ ];
89
+ }
90
+
91
+ export function renderSplashToString(props, { columns = 80 } = {}) {
92
+ void columns; // currently fixed-width at 80; <72 fallback is in cli.mjs
93
+ const body = buildBody(props);
94
+ const footer = buildFooter(props);
95
+ return [...body, '', ...footer].join('\n');
96
+ }
97
+
98
+ export function Splash(props) {
99
+ const lines = renderSplashToString(props).split('\n');
100
+ return React.createElement(
101
+ Box,
102
+ { flexDirection: 'column' },
103
+ lines.map((line, i) =>
104
+ React.createElement(Text, { key: i, color: i < banner.height ? theme.fg : undefined }, line)
105
+ )
106
+ );
107
+ }
package/tui/theme.mjs ADDED
@@ -0,0 +1,39 @@
1
+ // tui/theme.mjs — single source of truth for lazyclaw v5 color tokens.
2
+ // The amber hex is also stamped into tui/banner.generated.mjs so the
3
+ // sloth gutter and the prompt accent stay visually paired.
4
+ import chalk from 'chalk';
5
+
6
+ const AMBER_HEX = '#FFB347';
7
+
8
+ function amber(text) {
9
+ return chalk.hex(AMBER_HEX)(text);
10
+ }
11
+
12
+ function dim(text) {
13
+ return chalk.dim(text);
14
+ }
15
+
16
+ function accent(text) {
17
+ return chalk.bold.hex(AMBER_HEX)(text);
18
+ }
19
+
20
+ function muted(text) {
21
+ return chalk.gray(text);
22
+ }
23
+
24
+ function plain(text) {
25
+ return text;
26
+ }
27
+
28
+ export const theme = {
29
+ amber: AMBER_HEX,
30
+ fg: AMBER_HEX,
31
+ colorize: amber,
32
+ dim,
33
+ accent,
34
+ muted,
35
+ plain,
36
+ };
37
+
38
+ // Compatibility: some callers want the colorizer when reading `theme.amber`.
39
+ // Keep the hex on the property for builders, and expose `colorize` for runtime use.