Skip to content

Commit 30d8665

Browse files
committed
Replace bash ctools with Python CLI, fix search_models and consolidation bugs
Rewrites ctools as a Python module (clarvis.cli.ctools) that uses DaemonClient directly — eliminates shell quoting issues (apostrophes), nc dependency, and 3 Python subprocess spawns per invocation. Auto-discovers commands from handler signatures with type-aware key=value coercion. Fixes search_models reading wrong dict key ("results" vs "mental_models") which caused it to always return empty. Adds tag diagnostic on empty results. Fixes consolidation: skipped counter, uuid coercion, observation enrichment. Simplifies reflect skill and factoria grounding docs.
1 parent 321c071 commit 30d8665

File tree

11 files changed

+689
-178
lines changed

11 files changed

+689
-178
lines changed

bin/ctools

Lines changed: 2 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,8 @@
11
#!/usr/bin/env bash
22
# ctools — Agent CLI for Clarvis daemon services.
3-
# Usage: ctools <command> [json_params]
4-
# ctools <command> - < file.json
5-
# cat file.json | ctools <command> -
6-
# Example: ctools recall '{"query": "music taste"}'
7-
# ctools timer '{"action": "set", "name": "tea", "duration": "5m"}'
8-
9-
set -euo pipefail
10-
11-
# Resolve project root (follows symlinks)
3+
# Thin shim that delegates to the Python CLI module.
124
REAL_SCRIPT="$(readlink -f "$0" 2>/dev/null || python3 -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "$0")"
135
SCRIPT_DIR="$(cd "$(dirname "$REAL_SCRIPT")/.." && pwd)"
146
PYTHON="${SCRIPT_DIR}/.venv/bin/python"
157
[[ -x "$PYTHON" ]] || PYTHON="python3"
16-
17-
# --- Help ---
18-
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" || -z "${1:-}" ]]; then
19-
"$PYTHON" - "$SCRIPT_DIR" <<'PYEOF'
20-
import sys, importlib, inspect
21-
22-
sys.path.insert(0, sys.argv[1])
23-
from clarvis.core.commands import _DOMAIN_MODULES
24-
25-
for mod in _DOMAIN_MODULES:
26-
mod_label = mod.__name__.rsplit(".", 1)[-1]
27-
cmds = getattr(mod, "COMMANDS", {})
28-
if not cmds:
29-
continue
30-
print(f"\n {mod_label}")
31-
print(f" {'─' * 40}")
32-
for ipc_name, fn_name in cmds.items():
33-
fn = getattr(mod, fn_name, None)
34-
if fn is None:
35-
continue
36-
sig = inspect.signature(fn)
37-
params = [
38-
p for p in sig.parameters.values()
39-
if p.name not in ("self", "kw") and p.kind != p.VAR_KEYWORD
40-
]
41-
param_strs = []
42-
for p in params:
43-
if p.default is inspect.Parameter.empty:
44-
param_strs.append(p.name)
45-
else:
46-
param_strs.append(f"{p.name}={p.default!r}")
47-
param_line = f" ({', '.join(param_strs)})" if param_strs else ""
48-
doc = (fn.__doc__ or "").strip().split("\n")[0]
49-
print(f" {ipc_name}{param_line}")
50-
if doc:
51-
print(f" {doc}")
52-
print()
53-
PYEOF
54-
exit 0
55-
fi
56-
57-
# --- Execute command ---
58-
SOCKET="/tmp/clarvis-daemon.sock"
59-
METHOD="$1"
60-
61-
# Read params: from stdin if "-" flag or if stdin is piped (no second arg)
62-
if [[ "${2:-}" == "-" ]]; then
63-
PARAMS="$(cat)"
64-
elif [[ -n "${2:-}" ]]; then
65-
PARAMS="$2"
66-
elif [[ ! -t 0 ]]; then
67-
PARAMS="$(cat)"
68-
else
69-
PARAMS="{}"
70-
fi
71-
72-
# Build single-line request JSON via Python (handles newlines, quotes, special chars)
73-
# then send to daemon and parse response
74-
"$PYTHON" -c "
75-
import sys, json
76-
77-
method, params_raw = sys.argv[1], sys.argv[2]
78-
try:
79-
params = json.loads(params_raw)
80-
except (json.JSONDecodeError, ValueError):
81-
print(f'Invalid params JSON: {params_raw[:200]}', file=sys.stderr)
82-
sys.exit(1)
83-
print(json.dumps({'method': method, 'params': params}))
84-
" "$METHOD" "$PARAMS" \
85-
| nc -U "$SOCKET" -w 5 \
86-
| "$PYTHON" -c "
87-
import sys, json
88-
raw = sys.stdin.read().strip()
89-
if not raw:
90-
print('No response from daemon', file=sys.stderr); sys.exit(1)
91-
data = json.loads(raw.split('\n')[0])
92-
if 'error' in data:
93-
print(data['error'], file=sys.stderr); sys.exit(1)
94-
r = data.get('result', '')
95-
print(r if isinstance(r, str) else json.dumps(r, indent=2))
96-
"
8+
exec "$PYTHON" -m clarvis.cli.ctools "$@"

clarvis/cli/__init__.py

Whitespace-only changes.

clarvis/cli/ctools.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
"""ctools — Python CLI for Clarvis daemon services.
2+
3+
Usage: ctools <command> [key=value ...]
4+
ctools --help
5+
ctools --dump-grounding
6+
"""
7+
8+
import inspect
9+
import json
10+
import sys
11+
import types
12+
import typing
13+
from dataclasses import dataclass, field
14+
from typing import Any
15+
16+
17+
@dataclass
18+
class ParamSpec:
19+
name: str
20+
annotation: type | None
21+
default: Any # inspect.Parameter.empty if required
22+
required: bool
23+
24+
25+
@dataclass
26+
class CommandSpec:
27+
ipc_name: str
28+
module_name: str
29+
params: list[ParamSpec] = field(default_factory=list)
30+
doc: str = ""
31+
32+
33+
def _unwrap_optional(tp):
34+
"""Unwrap T | None → T. Returns (inner_type, was_optional)."""
35+
origin = typing.get_origin(tp)
36+
if origin is types.UnionType or origin is typing.Union:
37+
args = [a for a in typing.get_args(tp) if a is not type(None)]
38+
if len(args) == 1:
39+
return args[0], True
40+
return tp, False
41+
42+
43+
def _is_list_of(tp, inner):
44+
"""Check if tp is list[inner]."""
45+
return typing.get_origin(tp) is list and typing.get_args(tp) == (inner,)
46+
47+
48+
def coerce_value(value_str: str, spec: ParamSpec) -> Any:
49+
"""Convert a CLI string value to the type indicated by the ParamSpec annotation."""
50+
ann = spec.annotation
51+
if ann is None:
52+
# No annotation — try JSON parse, fall back to string
53+
try:
54+
return json.loads(value_str)
55+
except (json.JSONDecodeError, ValueError):
56+
return value_str
57+
58+
# Unwrap Optional
59+
inner, _ = _unwrap_optional(ann)
60+
61+
# Simple scalars
62+
if inner is str:
63+
return value_str
64+
if inner is int:
65+
return int(value_str)
66+
if inner is float:
67+
return float(value_str)
68+
if inner is bool:
69+
return value_str.lower() in ("true", "1", "yes")
70+
71+
# list[str] — comma-separated
72+
if _is_list_of(inner, str):
73+
return [s.strip() for s in value_str.split(",")]
74+
75+
# list[dict], dict, or other complex types — JSON parse
76+
if typing.get_origin(inner) is list or inner is list or inner is dict or typing.get_origin(inner) is dict:
77+
return json.loads(value_str)
78+
79+
# Fallback: try JSON, then string
80+
try:
81+
return json.loads(value_str)
82+
except (json.JSONDecodeError, ValueError):
83+
return value_str
84+
85+
86+
def build_registry() -> dict[str, CommandSpec]:
87+
"""Build command registry from domain module COMMANDS dicts and handler signatures."""
88+
from clarvis.core.commands import _DOMAIN_MODULES
89+
90+
registry: dict[str, CommandSpec] = {}
91+
for mod in _DOMAIN_MODULES:
92+
mod_label = mod.__name__.rsplit(".", 1)[-1]
93+
commands = getattr(mod, "COMMANDS", {})
94+
for ipc_name, fn_name in commands.items():
95+
fn = getattr(mod, fn_name, None)
96+
if fn is None:
97+
continue
98+
sig = inspect.signature(fn)
99+
try:
100+
hints = typing.get_type_hints(fn)
101+
except Exception:
102+
hints = {}
103+
104+
params = []
105+
for p in sig.parameters.values():
106+
if p.name == "self" or p.name == "kw" or p.kind == p.VAR_KEYWORD:
107+
continue
108+
params.append(
109+
ParamSpec(
110+
name=p.name,
111+
annotation=hints.get(p.name),
112+
default=p.default,
113+
required=p.default is inspect.Parameter.empty,
114+
)
115+
)
116+
117+
doc = (fn.__doc__ or "").strip().split("\n")[0]
118+
registry[ipc_name] = CommandSpec(
119+
ipc_name=ipc_name,
120+
module_name=mod_label,
121+
params=params,
122+
doc=doc,
123+
)
124+
return registry
125+
126+
127+
def parse_args(spec: CommandSpec, raw_args: list[str]) -> dict[str, Any]:
128+
"""Parse key=value arguments into a dict, coercing types based on CommandSpec."""
129+
result = {}
130+
param_map = {p.name: p for p in spec.params}
131+
132+
for arg in raw_args:
133+
if "=" not in arg:
134+
raise ValueError(f"Invalid argument (expected key=value): {arg}")
135+
key, value = arg.split("=", 1)
136+
ps = param_map.get(key)
137+
if ps is None:
138+
# Unknown param — pass through as-is (handlers accept **kw)
139+
try:
140+
result[key] = json.loads(value)
141+
except (json.JSONDecodeError, ValueError):
142+
result[key] = value
143+
continue
144+
result[key] = coerce_value(value, ps)
145+
146+
# Check required params
147+
missing = [p.name for p in spec.params if p.required and p.name not in result]
148+
if missing:
149+
raise ValueError(f"Missing required parameter(s): {', '.join(missing)}")
150+
151+
return result
152+
153+
154+
def _format_param(p: ParamSpec) -> str:
155+
"""Format a single param for help display."""
156+
if p.required:
157+
return p.name
158+
return f"{p.name}={p.default!r}"
159+
160+
161+
def print_help(registry: dict[str, CommandSpec]) -> None:
162+
"""Print help to stdout."""
163+
# Group by module
164+
by_module: dict[str, list[CommandSpec]] = {}
165+
for spec in registry.values():
166+
by_module.setdefault(spec.module_name, []).append(spec)
167+
168+
for mod_name, specs in by_module.items():
169+
print(f"\n {mod_name}")
170+
print(f" {'─' * 40}")
171+
for spec in specs:
172+
param_line = f" ({', '.join(_format_param(p) for p in spec.params)})" if spec.params else ""
173+
print(f" {spec.ipc_name}{param_line}")
174+
if spec.doc:
175+
print(f" {spec.doc}")
176+
print()
177+
178+
179+
def print_grounding(registry: dict[str, CommandSpec]) -> None:
180+
"""Print grounding markdown to stdout."""
181+
by_module: dict[str, list[CommandSpec]] = {}
182+
for spec in registry.values():
183+
by_module.setdefault(spec.module_name, []).append(spec)
184+
185+
print("# ctools — Daemon Commands\n")
186+
print("Usage: `ctools <command> [key=value ...]`\n")
187+
188+
for mod_name, specs in by_module.items():
189+
print(f"## {mod_name}\n")
190+
for spec in specs:
191+
params_parts = []
192+
for p in spec.params:
193+
ann = p.annotation
194+
inner, was_opt = _unwrap_optional(ann) if ann else (None, False)
195+
type_str = ""
196+
if inner is str:
197+
type_str = "str"
198+
elif inner is int:
199+
type_str = "int"
200+
elif inner is float:
201+
type_str = "float"
202+
elif inner is bool:
203+
type_str = "bool"
204+
elif _is_list_of(inner, str) if inner else False:
205+
type_str = "list (comma-separated)"
206+
elif inner is dict or (inner and typing.get_origin(inner) is dict):
207+
type_str = "JSON"
208+
elif inner and typing.get_origin(inner) is list:
209+
type_str = "JSON array"
210+
else:
211+
type_str = "str"
212+
213+
if p.required:
214+
params_parts.append(f" - `{p.name}` ({type_str}) — required")
215+
else:
216+
params_parts.append(f" - `{p.name}` ({type_str}, default: {p.default!r})")
217+
218+
print(f"**{spec.ipc_name}** — {spec.doc}")
219+
if params_parts:
220+
print("\n".join(params_parts))
221+
print()
222+
223+
224+
def main() -> None:
225+
"""Entry point for ctools CLI."""
226+
args = sys.argv[1:]
227+
228+
if not args or args[0] in ("--help", "-h"):
229+
registry = build_registry()
230+
print_help(registry)
231+
return
232+
233+
if args[0] == "--dump-grounding":
234+
registry = build_registry()
235+
print_grounding(registry)
236+
return
237+
238+
command = args[0]
239+
raw_args = args[1:]
240+
241+
# Handle stdin pipe via '-' flag
242+
if raw_args == ["-"] or (not raw_args and not sys.stdin.isatty()):
243+
stdin_data = sys.stdin.read().strip()
244+
if stdin_data:
245+
# Try as JSON first (backward compat for piped JSON)
246+
try:
247+
params = json.loads(stdin_data)
248+
if isinstance(params, dict):
249+
raw_args = [f"{k}={json.dumps(v) if not isinstance(v, str) else v}" for k, v in params.items()]
250+
else:
251+
raw_args = []
252+
except (json.JSONDecodeError, ValueError):
253+
# Treat as key=value lines
254+
raw_args = [line.strip() for line in stdin_data.splitlines() if "=" in line]
255+
256+
registry = build_registry()
257+
spec = registry.get(command)
258+
if spec is None:
259+
print(f"Unknown command: {command}", file=sys.stderr)
260+
print("Run 'ctools --help' to see available commands.", file=sys.stderr)
261+
sys.exit(1)
262+
263+
try:
264+
params = parse_args(spec, raw_args)
265+
except ValueError as e:
266+
print(f"Error: {e}", file=sys.stderr)
267+
sys.exit(1)
268+
269+
# Call daemon
270+
from clarvis.core.ipc import DaemonClient
271+
272+
try:
273+
client = DaemonClient()
274+
result = client.call(command, **params)
275+
except ConnectionError as e:
276+
print(str(e), file=sys.stderr)
277+
sys.exit(1)
278+
except RuntimeError as e:
279+
print(str(e), file=sys.stderr)
280+
sys.exit(1)
281+
282+
# Print result
283+
if result is None:
284+
pass
285+
elif isinstance(result, str):
286+
print(result)
287+
else:
288+
print(json.dumps(result, indent=2))
289+
290+
291+
if __name__ == "__main__":
292+
main()

0 commit comments

Comments
 (0)