From e8ef2fd450348567fce89edf6b6dbe81acc3115d Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 2 May 2026 20:48:44 -0700 Subject: [PATCH 1/3] feat: add ai-skill and ai-memory hive CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the two new typed hives added in legion_config_hive: ai_skill (Claude Code skill definitions) and ai_memory (per-agent memory store with a server-side partial-merge hook). ai-skill is a straight hive shortcut on top of make_hive_group: list, get, set, delete, enable, disable. ai-memory is custom because its hook lets Set on a single memory name update only that one entry while every other memory on the record is preserved by the server. The new AiMemory SDK class bakes that mechanism in: set/delete take a required --memory-name and send {memories: {name: content|null}} only — no GET-then-PUT round trip, no etag race window. delete-record removes the whole agent record through the regular DELETE verb. Both hive names are also added to Configs.ALL_HIVES, the sync flag map (--hive-ai-skill, --hive-ai-memory), the hive list-types output, and the lazy-loading regression snapshot. Co-Authored-By: Claude Opus 4.7 (1M context) --- NEW_CLI.md | 2 + limacharlie/cli.py | 2 + limacharlie/commands/ai_memory.py | 289 ++++++++++++++++++ limacharlie/commands/ai_skill.py | 92 ++++++ limacharlie/commands/hive.py | 9 +- limacharlie/commands/sync.py | 16 +- limacharlie/sdk/ai_memory.py | 223 ++++++++++++++ limacharlie/sdk/configs.py | 2 + tests/unit/test_cli_ai_skill_memory.py | 278 +++++++++++++++++ .../unit/test_cli_lazy_loading_regression.py | 22 +- tests/unit/test_sdk_ai_memory.py | 154 ++++++++++ tests/unit/test_sdk_configs.py | 3 +- 12 files changed, 1079 insertions(+), 13 deletions(-) create mode 100644 limacharlie/commands/ai_memory.py create mode 100644 limacharlie/commands/ai_skill.py create mode 100644 limacharlie/sdk/ai_memory.py create mode 100644 tests/unit/test_cli_ai_skill_memory.py create mode 100644 tests/unit/test_sdk_ai_memory.py diff --git a/NEW_CLI.md b/NEW_CLI.md index e2bd8620..0eb31cce 100644 --- a/NEW_CLI.md +++ b/NEW_CLI.md @@ -1246,6 +1246,8 @@ Features in the API gateway that are **NOT** in the current CLI and are **added | `query` | Saved LCQL queries | Reusable queries | | `playbook` | Automation playbooks | Response automation | | `ai_agent` | AI agent configurations | AI agent settings | +| `ai_skill` | Claude Code skill definitions | Reusable AI skill specs | +| `ai_memory` | AI agent memories (partial-merge) | Per-agent memory store | | `external_adapter` | External adapter configs | Third-party integrations | | `sop` | Standard Operating Procedures | Runbooks | | `note` | Organization notes | Documentation | diff --git a/limacharlie/cli.py b/limacharlie/cli.py index f518088a..c37c59b6 100644 --- a/limacharlie/cli.py +++ b/limacharlie/cli.py @@ -95,6 +95,8 @@ def _config_no_warnings() -> bool: # The regression test TestModuleMapping verifies this stays in sync. _COMMAND_MODULE_MAP: dict[str, tuple[str, str]] = { "ai": ("ai", "group"), + "ai-memory": ("ai_memory", "group"), + "ai-skill": ("ai_skill", "group"), "api": ("api_cmd", "cmd"), "api-key": ("api_key", "group"), "arl": ("arl", "group"), diff --git a/limacharlie/commands/ai_memory.py b/limacharlie/commands/ai_memory.py new file mode 100644 index 00000000..036c6e3f --- /dev/null +++ b/limacharlie/commands/ai_memory.py @@ -0,0 +1,289 @@ +"""AI memory commands for LimaCharlie CLI v2. + +Wraps the ``ai_memory`` hive — a per-agent key/value store with a +server-side partial-merge hook. Each Hive record (keyed by an agent +identifier) holds a ``memories`` map of filesystem-style names to +memory contents. Submitting a single memory name updates only that +entry; the rest of the record is preserved by the hook. + +Every read/write/delete sub-command requires both ``--key`` (the agent +identifier / hive record key) and ``--memory-name`` (the entry within +that record). Use ``delete-record`` to remove an entire agent record. +""" + +from __future__ import annotations + +import sys +from typing import Any + +import click + +from ..cli import pass_context +from ..client import Client +from ..sdk.ai_memory import AiMemory +from ..sdk.organization import Organization +from ..output import format_output, detect_output_format +from ..discovery import register_explain + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get_org(ctx: click.Context) -> Organization: + client = Client( + oid=ctx.obj.oid, + environment=ctx.obj.environment, + print_debug_fn=ctx.obj.debug_fn, + debug_full_response=ctx.obj.debug_full, + debug_curl=ctx.obj.debug_curl, + debug_verbose=ctx.obj.debug_verbose, + ) + return Organization(client) + + +def _output(ctx: click.Context, data: Any) -> None: + fmt = ctx.obj.output_format or detect_output_format() + if not ctx.obj.quiet: + click.echo(format_output(data, fmt)) + + +def _read_content(content: str | None, input_file: str | None) -> str: + """Resolve memory content from --content, --input-file, or stdin.""" + if content is not None: + return content + if input_file: + with open(input_file, "r") as f: + return f.read() + if not sys.stdin.isatty(): + return sys.stdin.read() + raise click.UsageError( + "Provide memory content via --content, --input-file, or stdin." + ) + + +# --------------------------------------------------------------------------- +# Group +# --------------------------------------------------------------------------- + +@click.group("ai-memory") +def group() -> None: + """Manage AI agent memory entries (partial-merge hive). + + Each agent has one record in the ``ai_memory`` hive, keyed by its + identifier. Within that record, individual memories are addressed + by ``--memory-name``. Writes are partial: a Set on one memory name + leaves the other memories on the same record untouched. + """ + + +# --------------------------------------------------------------------------- +# list-records +# --------------------------------------------------------------------------- + +_EXPLAIN_LIST_RECORDS = """\ +List every agent record stored in the ``ai_memory`` hive. The output +is keyed by agent identifier; the per-agent ``memories`` map (if any) +appears under ``data.memories``. + +To list memory entries for a single agent, use ``ai-memory list`` +with ``--key ``. +""" +register_explain("ai-memory.list-records", _EXPLAIN_LIST_RECORDS) + + +@group.command("list-records") +@pass_context +def list_records(ctx) -> None: + """List every agent record (and their memory maps).""" + org = _get_org(ctx) + am = AiMemory(org) + records = am.list_records() + _output(ctx, {name: rec.to_dict() for name, rec in records.items()}) + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + +_EXPLAIN_LIST = """\ +List the memory entries stored under one agent record. Returns a flat +mapping of memory name to memory content for the record identified by +``--key``. + +Use ``ai-memory list-records`` to enumerate every agent. +""" +register_explain("ai-memory.list", _EXPLAIN_LIST) + + +@group.command("list") +@click.option("--key", required=True, help="Agent identifier (hive record key).") +@pass_context +def list_memories(ctx, key) -> None: + """List memory entries for one agent record.""" + org = _get_org(ctx) + am = AiMemory(org) + _output(ctx, am.list_memories(key)) + + +# --------------------------------------------------------------------------- +# get +# --------------------------------------------------------------------------- + +_EXPLAIN_GET = """\ +Fetch a single memory entry's content by name. Returns the raw +content string for the memory ``--memory-name`` within the agent +record ``--key``. + +If the memory does not exist on the record, exits non-zero with an +empty result. +""" +register_explain("ai-memory.get", _EXPLAIN_GET) + + +@group.command("get") +@click.option("--key", required=True, help="Agent identifier (hive record key).") +@click.option("--memory-name", required=True, help="Memory entry name within the agent record.") +@pass_context +def get_memory(ctx, key, memory_name) -> None: + """Get a single memory entry's content.""" + org = _get_org(ctx) + am = AiMemory(org) + content = am.get(key, memory_name) + if content is None: + if not ctx.obj.quiet: + click.echo( + f"Error: memory '{memory_name}' not found on agent '{key}'.", + err=True, + ) + ctx.exit(4) + return + fmt = ctx.obj.output_format or detect_output_format() + if fmt in ("json", "yaml", "toon"): + _output(ctx, {"key": key, "memory_name": memory_name, "content": content}) + else: + click.echo(content) + + +# --------------------------------------------------------------------------- +# set +# --------------------------------------------------------------------------- + +_EXPLAIN_SET = """\ +Create or replace a single memory entry on an agent record. Sends +``{"memories": {: }}`` only; the server-side +partial-merge hook leaves every other memory on the record untouched. + +Content sources (in priority order): + 1. ``--content`` flag + 2. ``--input-file`` path + 3. stdin (when piped) + +Examples: + limacharlie ai-memory set --key triage-bot --memory-name notes/today \\ + --content "wrote the cli wrapper" + + cat notes.md | limacharlie ai-memory set --key triage-bot \\ + --memory-name notes/today + +The ``--memory-name`` follows a filesystem-style naming rule (relative +path, forward slashes only, no traversal above the record root, max +256 chars). The hive enforces these rules server-side. +""" +register_explain("ai-memory.set", _EXPLAIN_SET) + + +@group.command("set") +@click.option("--key", required=True, help="Agent identifier (hive record key).") +@click.option("--memory-name", required=True, help="Memory entry name within the agent record.") +@click.option("--content", default=None, help="Memory content (string). If omitted, reads --input-file or stdin.") +@click.option("--input-file", type=click.Path(exists=True), default=None, help="Path to a file whose contents become the memory.") +@pass_context +def set_memory(ctx, key, memory_name, content, input_file) -> None: + """Create or replace one memory entry (partial-merge).""" + body = _read_content(content, input_file) + org = _get_org(ctx) + am = AiMemory(org) + result = am.set(key, memory_name, body) + if not ctx.obj.quiet: + click.echo( + f"Memory '{memory_name}' set on agent '{key}' " + f"(other memories on the record are preserved)." + ) + _output(ctx, result) + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + +_EXPLAIN_DELETE = """\ +Delete a single memory entry from an agent record. Sends +``{"memories": {: null}}`` so the partial-merge hook +drops just that one entry; every other memory on the record is +preserved. + +Use ``ai-memory delete-record`` to remove the whole agent record. + +Requires --confirm. +""" +register_explain("ai-memory.delete", _EXPLAIN_DELETE) + + +@group.command("delete") +@click.option("--key", required=True, help="Agent identifier (hive record key).") +@click.option("--memory-name", required=True, help="Memory entry name within the agent record.") +@click.option("--confirm", is_flag=True, default=False, help="Confirm deletion (required).") +@pass_context +def delete_memory(ctx, key, memory_name, confirm) -> None: + """Delete one memory entry (partial-merge).""" + if not confirm: + click.echo( + "Error: Destructive operation requires --confirm flag.\n" + "Suggestion: Re-run with --confirm to drop the memory entry.", + err=True, + ) + ctx.exit(4) + return + org = _get_org(ctx) + am = AiMemory(org) + result = am.delete(key, memory_name) + if not ctx.obj.quiet: + click.echo(f"Memory '{memory_name}' dropped from agent '{key}'.") + _output(ctx, result) + + +# --------------------------------------------------------------------------- +# delete-record +# --------------------------------------------------------------------------- + +_EXPLAIN_DELETE_RECORD = """\ +Delete an entire agent record from the ``ai_memory`` hive. This drops +every memory entry the agent had stored. Use ``ai-memory delete`` +with --memory-name to drop a single entry instead. + +Requires --confirm. +""" +register_explain("ai-memory.delete-record", _EXPLAIN_DELETE_RECORD) + + +@group.command("delete-record") +@click.option("--key", required=True, help="Agent identifier (hive record key) to delete entirely.") +@click.option("--confirm", is_flag=True, default=False, help="Confirm deletion (required).") +@pass_context +def delete_record(ctx, key, confirm) -> None: + """Delete an entire agent record (all memories).""" + if not confirm: + click.echo( + "Error: Destructive operation requires --confirm flag.\n" + "Suggestion: Re-run with --confirm to delete the entire agent record.", + err=True, + ) + ctx.exit(4) + return + org = _get_org(ctx) + am = AiMemory(org) + result = am.delete_record(key) + if not ctx.obj.quiet: + click.echo(f"Agent record '{key}' deleted (all memories dropped).") + _output(ctx, result) diff --git a/limacharlie/commands/ai_skill.py b/limacharlie/commands/ai_skill.py new file mode 100644 index 00000000..85673011 --- /dev/null +++ b/limacharlie/commands/ai_skill.py @@ -0,0 +1,92 @@ +"""AI skill commands for LimaCharlie CLI v2. + +Wraps the ``ai_skill`` hive — a typed store for Claude Code Skill +definitions. Each record is the structured equivalent of an on-disk +SKILL.md directory: the SKILL.md frontmatter is broken out into typed +fields (description, when_to_use, allowed-tools, effort, context, +arguments, hooks, paths, shell, ...), the SKILL.md body lives in +``content``, and bundled supporting files (scripts, reference docs) +live in ``files`` keyed by path-relative-to-skill-root. + +Reference: https://code.claude.com/docs/en/skills.md +""" + +from __future__ import annotations + +from ._hive_shortcut import make_hive_group +from ..discovery import register_explain + +group = make_hive_group("ai-skill", "ai_skill", "AI skill", "AI skills") + +# Override the generic hive explains with ai_skill-specific documentation. + +register_explain("ai-skill.list", """\ +List all Claude Code skill definitions stored in the 'ai_skill' hive. +Each record is a self-contained skill (SKILL.md body + frontmatter + +optional supporting files), keyed by skill name. +""") + +register_explain("ai-skill.get", """\ +Get a single skill record by key. Returns the full SKILL.md body +(content), all frontmatter fields (description, when_to_use, +allowed-tools, effort, context, arguments, hooks, paths, shell, ...), +and any bundled supporting files keyed by their relative path. +""") + +register_explain("ai-skill.set", """\ +Create or update a Claude Code skill definition. The data payload +mirrors the on-disk SKILL.md schema with one extension: bundled files +live under a 'files' map keyed by path-relative-to-skill-root. + +Required: + data: + content: | # SKILL.md body (markdown — the prompt) + Find duplicate IOCs across recent detections and report counts. + +Optional frontmatter (mirrors official YAML keys verbatim): + data: + content: "..." + name: my-skill # slug, [a-z0-9-]{1,64}; defaults to record key + description: "..." # listing summary; combined with when_to_use + # under a 1536 char ceiling + when_to_use: "..." # supplementary trigger context + argument-hint: "[issue-number]" + arguments: ["target", "since"] # or "target since" + disable-model-invocation: false + user-invocable: true + allowed-tools: ["Bash(git:*)", "Read"] # or space-separated string + model: "inherit" + effort: low|medium|high|xhigh|max + context: fork + agent: my-agent-type # only meaningful when context=fork + hooks: { ... } # opaque pass-through + paths: ["src/**/*.go"] # or comma-separated string + shell: bash|powershell + +Bundled supporting files (hive-only extension, max 100 entries): + data: + content: "..." + files: + scripts/helper.sh: "#!/bin/bash\\n..." + reference/api.md: "# API notes\\n..." + +Provide data via --input-file (YAML/JSON) or pipe through stdin. + +Examples: + limacharlie ai-skill set --key triage --input-file skill.yaml + cat skill.yaml | limacharlie ai-skill set --key triage +""") + +register_explain("ai-skill.delete", """\ +Delete a Claude Code skill record from the 'ai_skill' hive. Requires +--confirm. +""") + +register_explain("ai-skill.enable", """\ +Enable a skill record (sets usr_mtd.enabled=true). +""") + +register_explain("ai-skill.disable", """\ +Disable a skill record (sets usr_mtd.enabled=false). Disabled skills +remain stored but are skipped by Claude when listing available skills. +""") diff --git a/limacharlie/commands/hive.py b/limacharlie/commands/hive.py index c57a36a2..837e8651 100644 --- a/limacharlie/commands/hive.py +++ b/limacharlie/commands/hive.py @@ -94,6 +94,8 @@ def _record_from_input(key: str, data: Any) -> HiveRecord: "query", "playbook", "ai_agent", + "ai_skill", + "ai_memory", "external_adapter", "sop", "org_notes", @@ -136,6 +138,8 @@ def group() -> None: sop - Standard operating procedures org_notes - Organization notes ai_agent - AI agent configurations + ai_skill - Claude Code skill definitions + ai_memory - AI agent memories (partial-merge updates) Each record returned contains: data - The record payload (structure varies by hive type) @@ -454,11 +458,12 @@ def rename(ctx, hive_name, key, new_name) -> None: data for a LimaCharlie organization. Each hive type stores a specific kind of data. Known types: dr-general, dr-managed, dr-service, fp, cloud_sensor, extension_config, yara, lookup, secret, query, playbook, -ai_agent, external_adapter, sop, org_notes. +ai_agent, ai_skill, ai_memory, external_adapter, sop, org_notes. Use these names with --hive-name in other hive commands. Some hive types also have dedicated shortcut commands (e.g. "limacharlie lookup", -"limacharlie secret", "limacharlie yara"). +"limacharlie secret", "limacharlie yara", "limacharlie ai-skill", +"limacharlie ai-memory"). """ register_explain("hive.list-types", _EXPLAIN_LIST_TYPES) diff --git a/limacharlie/commands/sync.py b/limacharlie/commands/sync.py index 9305c379..40888bc1 100644 --- a/limacharlie/commands/sync.py +++ b/limacharlie/commands/sync.py @@ -65,6 +65,8 @@ def _get_org(ctx: click.Context) -> Organization: click.option("--hive-query", is_flag=True, default=False, help="Sync saved queries (hive)."), click.option("--hive-playbook", is_flag=True, default=False, help="Sync playbooks (hive)."), click.option("--hive-ai-agent", is_flag=True, default=False, help="Sync AI agents (hive)."), + click.option("--hive-ai-skill", is_flag=True, default=False, help="Sync AI skills (hive)."), + click.option("--hive-ai-memory", is_flag=True, default=False, help="Sync AI agent memories (hive)."), click.option("--hive-external-adapter", is_flag=True, default=False, help="Sync external adapters (hive)."), ] @@ -82,6 +84,8 @@ def _get_org(ctx: click.Context) -> Organization: "hive_query": "query", "hive_playbook": "playbook", "hive_ai_agent": "ai_agent", + "hive_ai_skill": "ai_skill", + "hive_ai_memory": "ai_memory", "hive_external_adapter": "external_adapter", } @@ -201,6 +205,8 @@ def group() -> None: --hive-query Saved queries --hive-playbook Playbooks --hive-ai-agent AI agents + --hive-ai-skill AI skills (Claude Code skill definitions) + --hive-ai-memory AI agent memories (partial-merge updates) --hive-external-adapter External adapters Examples: @@ -224,7 +230,8 @@ def pull(ctx, config_file, sync_all, outputs, integrity, hive_dr_general, hive_dr_managed, hive_dr_service, hive_fp, hive_cloud_sensor, hive_extension_config, hive_yara, hive_lookup, hive_secret, hive_query, - hive_playbook, hive_ai_agent, hive_external_adapter) -> None: + hive_playbook, hive_ai_agent, hive_ai_skill, hive_ai_memory, + hive_external_adapter) -> None: flags = _resolve_sync_flags( sync_all, outputs, integrity, exfil, artifact, resources, extensions, org_values, @@ -241,6 +248,8 @@ def pull(ctx, config_file, sync_all, outputs, integrity, hive_query=hive_query, hive_playbook=hive_playbook, hive_ai_agent=hive_ai_agent, + hive_ai_skill=hive_ai_skill, + hive_ai_memory=hive_ai_memory, hive_external_adapter=hive_external_adapter, ) @@ -314,7 +323,8 @@ def push(ctx, config_file, force, dry_run, sync_all, outputs, hive_dr_general, hive_dr_managed, hive_dr_service, hive_fp, hive_cloud_sensor, hive_extension_config, hive_yara, hive_lookup, hive_secret, hive_query, - hive_playbook, hive_ai_agent, hive_external_adapter) -> None: + hive_playbook, hive_ai_agent, hive_ai_skill, hive_ai_memory, + hive_external_adapter) -> None: flags = _resolve_sync_flags( sync_all, outputs, integrity, exfil, artifact, resources, extensions, org_values, @@ -331,6 +341,8 @@ def push(ctx, config_file, force, dry_run, sync_all, outputs, hive_query=hive_query, hive_playbook=hive_playbook, hive_ai_agent=hive_ai_agent, + hive_ai_skill=hive_ai_skill, + hive_ai_memory=hive_ai_memory, hive_external_adapter=hive_external_adapter, ) diff --git a/limacharlie/sdk/ai_memory.py b/limacharlie/sdk/ai_memory.py new file mode 100644 index 00000000..60ed171e --- /dev/null +++ b/limacharlie/sdk/ai_memory.py @@ -0,0 +1,223 @@ +"""AI Memory SDK for LimaCharlie v2. + +Wraps the ``ai_memory`` hive, which stores per-agent memory entries. +Each Hive record is keyed by an agent identifier and holds a +``memories`` map of filesystem-style names to memory contents. + +The hive's PreIngest hook applies a partial-merge on every Set: keys +present in the incoming ``memories`` map replace (or, when the value +is JSON null, drop) the matching key in the stored record, while keys +absent from the incoming map are preserved untouched. This SDK bakes +that semantic in: + + * :meth:`AiMemory.set` sends a single named memory and trusts the + hook to merge it in — the rest of the record is never touched and + never has to round-trip through the client. + * :meth:`AiMemory.delete` sends ``{"memories": {name: None}}`` so + the hook drops just that one entry. + * :meth:`AiMemory.delete_record` removes the entire agent record + (no merge semantics involved). + +Reference (server-side merge hook): legion_config_hive +``hives/def_ai_memory.go`` — ``MergeAiMemory``. +""" + +from __future__ import annotations + +import json +from typing import Any, TYPE_CHECKING +from urllib.parse import quote as urlescape + +if TYPE_CHECKING: + from ..client import Client + from .organization import Organization + +from .hive import Hive, HiveRecord + + +# Hive name used by the ai_memory definition in legion_config_hive. +HIVE_NAME = "ai_memory" + +# Top-level field in the record's data dict that holds the memories map. +# Mirrors aiMemoryFieldKey in def_ai_memory.go. +MEMORIES_FIELD = "memories" + + +class AiMemory: + """Client for the ``ai_memory`` hive in a LimaCharlie organization. + + Usage:: + + am = AiMemory(org) + am.set("my-agent", "notes/today", "wrote the cli wrapper") + content = am.get("my-agent", "notes/today") + am.delete("my-agent", "notes/today") # drop one memory + am.delete_record("my-agent") # drop the whole agent + """ + + def __init__(self, org: Organization, partition_key: str | None = None) -> None: + """Initialize the AI memory client. + + Args: + org: Organization instance. + partition_key: Optional partition key (defaults to org OID). + """ + self._org = org + self._hive = Hive(org, HIVE_NAME, partition_key=partition_key) + + @property + def hive(self) -> Hive: + """The underlying :class:`Hive` instance for advanced callers.""" + return self._hive + + @property + def client(self) -> Client: + """The underlying API client.""" + return self._org.client + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + def list_records(self) -> dict[str, HiveRecord]: + """List every agent record in the ``ai_memory`` hive. + + Returns: + Mapping of record name (agent identifier) → :class:`HiveRecord`. + """ + return self._hive.list() + + def get_record(self, agent: str) -> HiveRecord: + """Fetch the full memory record for an agent. + + Args: + agent: Agent identifier (the hive record key). + + Returns: + :class:`HiveRecord` with the full ``memories`` map in + ``record.data["memories"]``. + """ + return self._hive.get(agent) + + def list_memories(self, agent: str) -> dict[str, str]: + """Return the agent's memories as a flat name → content map. + + Args: + agent: Agent identifier. + + Returns: + Dict of memory name to memory content. Empty dict when the + record exists but has no memories (or no ``memories`` field). + """ + record = self._hive.get(agent) + return _extract_memories(record) + + def get(self, agent: str, memory_name: str) -> str | None: + """Fetch a single memory entry's content. + + Args: + agent: Agent identifier (the hive record key). + memory_name: Name of the memory entry within the record. + + Returns: + The memory's content string, or ``None`` when the entry + does not exist on the record. + """ + memories = self.list_memories(agent) + return memories.get(memory_name) + + # ------------------------------------------------------------------ + # Write (partial merge) + # ------------------------------------------------------------------ + + def set(self, agent: str, memory_name: str, content: str) -> dict[str, Any]: + """Create or replace a single memory entry. + + Sends ``{"memories": {memory_name: content}}`` only — every other + memory on the record is preserved by the hive's PreIngest merge + hook. No prior fetch is required and no etag round-trip is + needed for concurrent updates of disjoint memory names. + + Args: + agent: Agent identifier (the hive record key). + memory_name: Name of the memory entry to write. + content: Memory content (string). + + Returns: + dict: API response. + """ + return self._partial_set(agent, {memory_name: content}) + + def set_many(self, agent: str, memories: dict[str, str | None]) -> dict[str, Any]: + """Set or drop multiple memory entries in one request. + + Args: + agent: Agent identifier. + memories: Map of memory name to content. A value of ``None`` + drops that memory entry on the server (per the merge hook). + + Returns: + dict: API response. + """ + return self._partial_set(agent, memories) + + def delete(self, agent: str, memory_name: str) -> dict[str, Any]: + """Delete a single memory entry from the agent record. + + Sends ``{"memories": {memory_name: None}}`` so the merge hook + drops just that key — the rest of the record is preserved. + + Args: + agent: Agent identifier. + memory_name: Memory entry name to drop. + + Returns: + dict: API response. + """ + return self._partial_set(agent, {memory_name: None}) + + def delete_record(self, agent: str) -> dict[str, Any]: + """Delete the entire memory record for an agent. + + Use :meth:`delete` to drop a single memory entry; this method + removes the whole hive record. + + Args: + agent: Agent identifier. + + Returns: + dict: API response. + """ + return self._hive.delete(agent) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _partial_set(self, agent: str, memories: dict[str, str | None]) -> dict[str, Any]: + """POST a partial ``memories`` payload to the hive endpoint. + + Goes around :meth:`Hive.set` so we can send ``null`` values + (Python ``None``) — :class:`HiveRecord` would strip them on the + way through ``json.dumps``-of-the-dataclass round-trip if we + relied on it. The merge hook on the server treats null values as + deletions for the matching memory name. + """ + payload = {MEMORIES_FIELD: memories} + return self.client.request( + "POST", + f"hive/{HIVE_NAME}/{self._hive._partition_key}/{urlescape(agent, safe='')}/data", + params={"data": json.dumps(payload)}, + ) + + +def _extract_memories(record: HiveRecord) -> dict[str, str]: + """Return the ``memories`` map from a record, normalized to a dict.""" + if record.data is None: + return {} + raw = record.data.get(MEMORIES_FIELD) + if raw is None: + return {} + if not isinstance(raw, dict): + return {} + return {k: v for k, v in raw.items() if isinstance(v, str)} diff --git a/limacharlie/sdk/configs.py b/limacharlie/sdk/configs.py index cbadc37d..fadbfcc1 100644 --- a/limacharlie/sdk/configs.py +++ b/limacharlie/sdk/configs.py @@ -52,6 +52,8 @@ class Configs: "query", "playbook", "ai_agent", + "ai_skill", + "ai_memory", "external_adapter", } diff --git a/tests/unit/test_cli_ai_skill_memory.py b/tests/unit/test_cli_ai_skill_memory.py new file mode 100644 index 00000000..c4a0bd69 --- /dev/null +++ b/tests/unit/test_cli_ai_skill_memory.py @@ -0,0 +1,278 @@ +"""CLI tests for the ai-skill and ai-memory shortcut groups. + +The ai-memory tests are the important ones: they pin down that the CLI +sends a partial-merge payload (``{memories: {name: content}}`` for set, +``{memories: {name: null}}`` for delete) rather than rewriting the +whole record. Other memory entries on the server stay put. +""" + +from __future__ import annotations + +import json +from unittest.mock import patch, MagicMock + +from click.testing import CliRunner + +from limacharlie.cli import cli + + +# --------------------------------------------------------------------------- +# ai-skill (delegates to the generic _hive_shortcut factory) +# --------------------------------------------------------------------------- + +class TestAiSkillCli: + @patch("limacharlie.commands._hive_shortcut.Client") + @patch("limacharlie.commands._hive_shortcut.Organization") + @patch("limacharlie.commands._hive_shortcut.Hive") + def test_list_calls_ai_skill_hive(self, mock_hive_cls, _org, _client): + mock_record = MagicMock() + mock_record.to_dict.return_value = {"data": {"content": "..."}, "usr_mtd": {}, "sys_mtd": {}} + mock_hive = MagicMock() + mock_hive.list.return_value = {"my-skill": mock_record} + mock_hive_cls.return_value = mock_hive + + result = CliRunner().invoke(cli, ["--output", "json", "ai-skill", "list"]) + assert result.exit_code == 0, result.output + # The shortcut must target the ai_skill hive name. + assert mock_hive_cls.call_args[0][1] == "ai_skill" + parsed = json.loads(result.output) + assert "my-skill" in parsed + + @patch("limacharlie.commands._hive_shortcut.Client") + @patch("limacharlie.commands._hive_shortcut.Organization") + @patch("limacharlie.commands._hive_shortcut.Hive") + def test_set_passes_record_data_through(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "e2"} + mock_hive_cls.return_value = mock_hive + + payload = {"data": {"content": "skill body", "effort": "high"}} + result = CliRunner().invoke( + cli, ["ai-skill", "set", "--key", "triage"], + input=json.dumps(payload), + ) + assert result.exit_code == 0, result.output + assert mock_hive_cls.call_args[0][1] == "ai_skill" + record = mock_hive.set.call_args[0][0] + assert record.name == "triage" + assert record.data == {"content": "skill body", "effort": "high"} + + def test_delete_requires_confirm(self): + # No mocks needed: the confirm check fires before any API call. + result = CliRunner().invoke(cli, ["ai-skill", "delete", "--key", "x"]) + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# ai-memory (custom commands with partial-merge payloads) +# --------------------------------------------------------------------------- + +def _post_payload(mock_client) -> dict: + """Decode the JSON payload the CLI POSTed for the most recent set/delete.""" + # The AiMemory SDK always uses the org's client.request directly. + args, kwargs = mock_client.request.call_args + assert args[0] == "POST", f"expected POST, got {args[0]}" + return json.loads(kwargs["params"]["data"]) + + +def _last_post_url(mock_client) -> str: + args, _ = mock_client.request.call_args + return args[1] + + +class TestAiMemorySetCli: + """A set on one memory_name must send only that one entry.""" + + @patch("limacharlie.commands.ai_memory.Client") + @patch("limacharlie.commands.ai_memory.Organization") + def test_set_with_content_flag(self, mock_org_cls, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_org = MagicMock() + mock_org.oid = "test-oid" + mock_org.client = mock_client + mock_org_cls.return_value = mock_org + + result = CliRunner().invoke(cli, [ + "ai-memory", "set", + "--key", "agent-A", + "--memory-name", "notes/today", + "--content", "wrote the cli wrapper", + ]) + assert result.exit_code == 0, result.output + + payload = _post_payload(mock_client) + # Critical: only the named memory is in the payload. The rest of + # the record (other memory names) must be preserved server-side + # by the partial-merge hook, not sent down the wire by the CLI. + assert payload == { + "memories": {"notes/today": "wrote the cli wrapper"} + } + assert "/agent-A/data" in _last_post_url(mock_client) + + @patch("limacharlie.commands.ai_memory.Client") + @patch("limacharlie.commands.ai_memory.Organization") + def test_set_with_stdin(self, mock_org_cls, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_org = MagicMock() + mock_org.oid = "test-oid" + mock_org.client = mock_client + mock_org_cls.return_value = mock_org + + result = CliRunner().invoke( + cli, ["ai-memory", "set", "--key", "agent-A", "--memory-name", "k"], + input="multi\nline\ncontent\n", + ) + assert result.exit_code == 0, result.output + + payload = _post_payload(mock_client) + assert payload == {"memories": {"k": "multi\nline\ncontent\n"}} + + @patch("limacharlie.commands.ai_memory.Client") + @patch("limacharlie.commands.ai_memory.Organization") + def test_set_does_not_get_first(self, mock_org_cls, mock_client_cls): + """Critical: the CLI must trust the merge hook, not round-trip + through GET to fetch the existing record.""" + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_org = MagicMock() + mock_org.oid = "test-oid" + mock_org.client = mock_client + mock_org_cls.return_value = mock_org + + result = CliRunner().invoke(cli, [ + "ai-memory", "set", + "--key", "agent-A", "--memory-name", "k", "--content", "v", + ]) + assert result.exit_code == 0, result.output + + # Single API call. No GET-then-PUT race window. + assert mock_client.request.call_count == 1 + + def test_set_requires_memory_name(self): + result = CliRunner().invoke(cli, [ + "ai-memory", "set", "--key", "agent-A", "--content", "v", + ]) + # Click reports missing required option as exit code 2. + assert result.exit_code != 0 + assert "memory-name" in result.output.lower() + + +class TestAiMemoryDeleteCli: + @patch("limacharlie.commands.ai_memory.Client") + @patch("limacharlie.commands.ai_memory.Organization") + def test_delete_sends_null_for_named_memory(self, mock_org_cls, mock_client_cls): + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_org = MagicMock() + mock_org.oid = "test-oid" + mock_org.client = mock_client + mock_org_cls.return_value = mock_org + + result = CliRunner().invoke(cli, [ + "ai-memory", "delete", + "--key", "agent-A", "--memory-name", "notes/today", + "--confirm", + ]) + assert result.exit_code == 0, result.output + + payload = _post_payload(mock_client) + # Server-side hook drops the entry on a JSON-null value. + assert payload == {"memories": {"notes/today": None}} + + def test_delete_requires_confirm(self): + result = CliRunner().invoke(cli, [ + "ai-memory", "delete", + "--key", "agent-A", "--memory-name", "k", + ]) + assert result.exit_code != 0 + + @patch("limacharlie.commands.ai_memory.Client") + @patch("limacharlie.commands.ai_memory.Organization") + def test_delete_record_uses_delete_verb(self, mock_org_cls, mock_client_cls): + """delete-record removes the whole agent record, not via the merge.""" + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_org = MagicMock() + mock_org.oid = "test-oid" + mock_org.client = mock_client + mock_org_cls.return_value = mock_org + + result = CliRunner().invoke(cli, [ + "ai-memory", "delete-record", + "--key", "agent-A", "--confirm", + ]) + assert result.exit_code == 0, result.output + + args, _ = mock_client.request.call_args + assert args[0] == "DELETE" + assert args[1].endswith("/agent-A") + + +class TestAiMemoryReadCli: + @patch("limacharlie.commands.ai_memory.Client") + @patch("limacharlie.commands.ai_memory.Organization") + def test_get_returns_only_named_memory(self, mock_org_cls, mock_client_cls): + mock_client = MagicMock() + mock_client.request.return_value = { + "data": {"memories": {"alpha": "A", "beta": "B"}}, + "usr_mtd": {}, + "sys_mtd": {}, + } + mock_client_cls.return_value = mock_client + mock_org = MagicMock() + mock_org.oid = "test-oid" + mock_org.client = mock_client + mock_org_cls.return_value = mock_org + + result = CliRunner().invoke(cli, [ + "--output", "json", + "ai-memory", "get", "--key", "agent-A", "--memory-name", "alpha", + ]) + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + assert parsed["content"] == "A" + assert parsed["memory_name"] == "alpha" + + @patch("limacharlie.commands.ai_memory.Client") + @patch("limacharlie.commands.ai_memory.Organization") + def test_get_missing_memory_exits_nonzero(self, mock_org_cls, mock_client_cls): + mock_client = MagicMock() + mock_client.request.return_value = { + "data": {"memories": {"alpha": "A"}}, + "usr_mtd": {}, + "sys_mtd": {}, + } + mock_client_cls.return_value = mock_client + mock_org = MagicMock() + mock_org.oid = "test-oid" + mock_org.client = mock_client + mock_org_cls.return_value = mock_org + + result = CliRunner().invoke(cli, [ + "ai-memory", "get", "--key", "agent-A", "--memory-name", "missing", + ]) + assert result.exit_code != 0 + + @patch("limacharlie.commands.ai_memory.Client") + @patch("limacharlie.commands.ai_memory.Organization") + def test_list_returns_flat_memory_map(self, mock_org_cls, mock_client_cls): + mock_client = MagicMock() + mock_client.request.return_value = { + "data": {"memories": {"alpha": "A", "beta": "B"}}, + "usr_mtd": {}, + "sys_mtd": {}, + } + mock_client_cls.return_value = mock_client + mock_org = MagicMock() + mock_org.oid = "test-oid" + mock_org.client = mock_client + mock_org_cls.return_value = mock_org + + result = CliRunner().invoke(cli, [ + "--output", "json", "ai-memory", "list", "--key", "agent-A", + ]) + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + assert parsed == {"alpha": "A", "beta": "B"} diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 725a3a5d..fddce0f8 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -45,14 +45,14 @@ # Every top-level command/group that must be registered on cli. EXPECTED_TOP_LEVEL_COMMANDS = frozenset({ - "ai", "api", "api-key", "arl", "artifact", "audit", "auth", "billing", - "case", "cloud-adapter", "completion", "config", "detection", "download", "dr", - "endpoint-policy", "event", "exfil", "extension", "external-adapter", "feedback", - "fp", "group", "help", "hive", "ingestion-key", "installation-key", - "integrity", "ioc", "job", "logging", "lookup", "note", "org", "output", - "payload", "playbook", "replay", "schema", "search", "secret", "sensor", - "sop", "spotcheck", "stream", "sync", "tag", "task", "user", "usp", - "yara", + "ai", "ai-memory", "ai-skill", "api", "api-key", "arl", "artifact", + "audit", "auth", "billing", "case", "cloud-adapter", "completion", "config", + "detection", "download", "dr", "endpoint-policy", "event", "exfil", + "extension", "external-adapter", "feedback", "fp", "group", "help", "hive", + "ingestion-key", "installation-key", "integrity", "ioc", "job", "logging", + "lookup", "note", "org", "output", "payload", "playbook", "replay", + "schema", "search", "secret", "sensor", "sop", "spotcheck", "stream", + "sync", "tag", "task", "user", "usp", "yara", }) # Module filename -> (attribute name, Click command name). @@ -60,6 +60,8 @@ EXPECTED_MODULE_MAP = { "adapter": ("group", "external-adapter"), "ai": ("group", "ai"), + "ai_memory": ("group", "ai-memory"), + "ai_skill": ("group", "ai-skill"), "api_cmd": ("cmd", "api"), "api_key": ("group", "api-key"), "arl": ("group", "arl"), @@ -119,6 +121,10 @@ "generate-response", "generate-rule", "generate-selector", "session", "start-session", "summarize-detection", "usage", }), + "ai-memory": frozenset({ + "delete", "delete-record", "get", "list", "list-records", "set", + }), + "ai-skill": frozenset({"delete", "disable", "enable", "get", "list", "set"}), "api-key": frozenset({"create", "delete", "list"}), "arl": frozenset({"get"}), "artifact": frozenset({"download", "list", "upload"}), diff --git a/tests/unit/test_sdk_ai_memory.py b/tests/unit/test_sdk_ai_memory.py new file mode 100644 index 00000000..94fcff70 --- /dev/null +++ b/tests/unit/test_sdk_ai_memory.py @@ -0,0 +1,154 @@ +"""Tests for limacharlie.sdk.ai_memory module. + +Focus: the partial-merge mechanism. The server-side hook is the source +of truth, but the SDK has to send the right shape ({memories: {name: +content}} for set, {memories: {name: null}} for delete) so the hook can +do its job. +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest + +from limacharlie.sdk.ai_memory import AiMemory, MEMORIES_FIELD, HIVE_NAME + + +@pytest.fixture +def mock_org(): + org = MagicMock() + org.oid = "test-oid" + org.client = MagicMock() + return org + + +@pytest.fixture +def am(mock_org): + return AiMemory(mock_org) + + +def _post_call(mock_org): + """Pull the most recent client.request POST call args.""" + args, kwargs = mock_org.client.request.call_args + assert args[0] == "POST" + return args[1], kwargs.get("params", {}) + + +class TestPartialSetSemantics: + """Set on one memory name must send only that key's payload.""" + + def test_set_sends_only_named_memory(self, am, mock_org): + am.set("agent-A", "notes/today", "wrote the cli wrapper") + url, params = _post_call(mock_org) + assert url == f"hive/{HIVE_NAME}/test-oid/agent-A/data" + payload = json.loads(params["data"]) + # Only the {memories: {name: content}} envelope - no other fields. + assert payload == {MEMORIES_FIELD: {"notes/today": "wrote the cli wrapper"}} + + def test_set_does_not_fetch_first(self, am, mock_org): + """Set must not round-trip through GET; the merge hook handles it.""" + am.set("agent-A", "notes/today", "content") + # Exactly one request, and it is the POST set. + assert mock_org.client.request.call_count == 1 + assert mock_org.client.request.call_args[0][0] == "POST" + + def test_set_url_escapes_agent_key(self, am, mock_org): + am.set("agent/with/slashes", "k", "v") + url, _ = _post_call(mock_org) + assert "agent%2Fwith%2Fslashes" in url + + def test_set_many_carries_every_entry(self, am, mock_org): + am.set_many("agent-A", {"a": "1", "b": "2", "c": None}) + _, params = _post_call(mock_org) + payload = json.loads(params["data"]) + assert payload == {MEMORIES_FIELD: {"a": "1", "b": "2", "c": None}} + + +class TestPartialDeleteSemantics: + """Delete on one memory name must send {name: null} so the hook drops it.""" + + def test_delete_sends_null_for_named_memory(self, am, mock_org): + am.delete("agent-A", "notes/today") + url, params = _post_call(mock_org) + assert url == f"hive/{HIVE_NAME}/test-oid/agent-A/data" + payload = json.loads(params["data"]) + assert payload == {MEMORIES_FIELD: {"notes/today": None}} + + def test_delete_preserves_null_through_json(self, am, mock_org): + """A regression guard: HiveRecord.set() would strip None on the way + through json.dumps; verify our partial path keeps it intact.""" + am.delete("agent-A", "k") + _, params = _post_call(mock_org) + # The literal "null" must appear in the wire payload. + assert ': null' in params["data"] or ':null' in params["data"] + + +class TestDeleteRecord: + def test_delete_record_uses_hive_delete(self, am, mock_org): + am.delete_record("agent-A") + # HiveDelete uses DELETE verb against the bare record path. + args, _ = mock_org.client.request.call_args + assert args[0] == "DELETE" + assert args[1] == f"hive/{HIVE_NAME}/test-oid/agent-A" + + +class TestRead: + def _record_response(self, memories): + return { + "data": {MEMORIES_FIELD: memories}, + "usr_mtd": {}, + "sys_mtd": {"etag": "e1"}, + } + + def test_get_returns_named_memory_content(self, am, mock_org): + mock_org.client.request.return_value = self._record_response({ + "alpha": "content-a", + "beta": "content-b", + }) + assert am.get("agent-A", "alpha") == "content-a" + + def test_get_missing_memory_returns_none(self, am, mock_org): + mock_org.client.request.return_value = self._record_response({"alpha": "a"}) + assert am.get("agent-A", "does-not-exist") is None + + def test_list_memories_returns_full_map(self, am, mock_org): + mock_org.client.request.return_value = self._record_response({ + "alpha": "a", "beta": "b", + }) + out = am.list_memories("agent-A") + assert out == {"alpha": "a", "beta": "b"} + + def test_list_memories_missing_field_returns_empty(self, am, mock_org): + mock_org.client.request.return_value = { + "data": {}, + "usr_mtd": {}, + "sys_mtd": {}, + } + assert am.list_memories("agent-A") == {} + + def test_list_records_lists_all_agents(self, am, mock_org): + mock_org.client.request.return_value = { + "agent-A": {"data": {MEMORIES_FIELD: {"a": "1"}}, "usr_mtd": {}, "sys_mtd": {}}, + "agent-B": {"data": {MEMORIES_FIELD: {}}, "usr_mtd": {}, "sys_mtd": {}}, + } + result = am.list_records() + assert set(result.keys()) == {"agent-A", "agent-B"} + # The hive list endpoint targets the partition root. + url = mock_org.client.request.call_args[0][1] + assert url == f"hive/{HIVE_NAME}/test-oid" + + +class TestPartitionKey: + def test_default_partition_is_org_oid(self, mock_org): + am = AiMemory(mock_org) + am.set("agent-A", "k", "v") + url, _ = _post_call(mock_org) + assert "/test-oid/" in url + + def test_custom_partition(self, mock_org): + am = AiMemory(mock_org, partition_key="custom-part") + am.set("agent-A", "k", "v") + url, _ = _post_call(mock_org) + assert "/custom-part/" in url diff --git a/tests/unit/test_sdk_configs.py b/tests/unit/test_sdk_configs.py index 53ebd584..9b9d5d54 100644 --- a/tests/unit/test_sdk_configs.py +++ b/tests/unit/test_sdk_configs.py @@ -200,7 +200,8 @@ def test_all_hives_constant(self): expected = { "dr-general", "dr-managed", "dr-service", "fp", "cloud_sensor", "extension_config", "yara", "lookup", - "secret", "query", "playbook", "ai_agent", "external_adapter", + "secret", "query", "playbook", "ai_agent", + "ai_skill", "ai_memory", "external_adapter", } assert Configs.ALL_HIVES == expected From 67905f67a33e3f12955f19d6ea0558c98a513a48 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sun, 3 May 2026 15:34:56 -0700 Subject: [PATCH 2/3] fix(hive): clear data envelope from get_metadata responses The /mtd endpoint serializes "data": {} into the response even though the call is metadata-only. HiveRecord.from_raw kept the empty dict, so a follow-up set() saw record.data != None and routed to /data with an empty payload. Hives whose validator requires fields (ai_skill needs content, ai_agent needs provider config, ...) then rejected the disable/enable flow with " is required". Drop the envelope in get_metadata so set() routes back to /mtd, where the validator-skip-on-metadata-only path applies and the round-trip preserves the record's data on the server. Surfaced testing the new ai-skill disable/enable shortcut against white-sands; secret/lookup escaped the bug only because their validators tolerate empty data. Co-Authored-By: Claude Opus 4.7 (1M context) --- limacharlie/sdk/hive.py | 9 ++++++++- tests/unit/test_sdk_hive.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/limacharlie/sdk/hive.py b/limacharlie/sdk/hive.py index 8b8539da..954ceab6 100644 --- a/limacharlie/sdk/hive.py +++ b/limacharlie/sdk/hive.py @@ -155,7 +155,14 @@ def get_metadata(self, record_name: str) -> HiveRecord: "GET", f"hive/{self._hive_name}/{self._partition_key}/{urlescape(record_name, safe='')}/mtd", ) - return HiveRecord.from_raw(record_name, resp) + record = HiveRecord.from_raw(record_name, resp) + # The /mtd endpoint serializes ``data`` as ``{}`` even though only + # metadata is being returned. Clearing the field here keeps + # ``set()`` on the round-tripped record routing to /mtd (rather + # than /data with an empty payload, which trips required-field + # validators on typed hives like ai_skill or ai_agent). + record.data = None + return record def set(self, record: HiveRecord) -> dict[str, Any]: """Create or update a record. diff --git a/tests/unit/test_sdk_hive.py b/tests/unit/test_sdk_hive.py index dc533331..8dfa1529 100644 --- a/tests/unit/test_sdk_hive.py +++ b/tests/unit/test_sdk_hive.py @@ -134,6 +134,27 @@ def test_get_metadata(self, hive, mock_org): "GET", "hive/dr-general/test-oid/my-key/mtd" ) + def test_get_metadata_clears_empty_data_envelope(self, hive, mock_org): + """The /mtd endpoint returns ``"data": {}`` even though it is a + metadata-only fetch. ``get_metadata`` must drop that envelope so + a subsequent ``set()`` stays on the /mtd routing path — otherwise + typed hives with required fields (ai_skill, ai_agent, ...) reject + the disable/enable flow on an empty data validator pass.""" + mock_org.client.request.return_value = { + "data": {}, + "usr_mtd": {"enabled": True}, + "sys_mtd": {"etag": "e3"}, + } + rec = hive.get_metadata("my-key") + assert rec.data is None + + # Round-trip: a follow-up set() must target /mtd, not /data. + mock_org.client.request.reset_mock() + rec.enabled = False + hive.set(rec) + url = mock_org.client.request.call_args[0][1] + assert url.endswith("/my-key/mtd"), url + class TestHiveSet: def test_set_record_with_data(self, hive, mock_org): From b82be10ef9de57b30e8b657ad693b4c4d7edb589 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sun, 3 May 2026 15:50:18 -0700 Subject: [PATCH 3/3] fix(test): give ai-memory CLI tests a serializable mock response The 5 failing tests mocked Client/Organization but never set a return value for client.request(), so the SDK call returned a MagicMock that the CLI's _output() then crashed on with "Type is not JSON serializable: MagicMock". Set request.return_value = {} on each so the CLI's serialization step gets a real dict. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/test_cli_ai_skill_memory.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/test_cli_ai_skill_memory.py b/tests/unit/test_cli_ai_skill_memory.py index c4a0bd69..9982b674 100644 --- a/tests/unit/test_cli_ai_skill_memory.py +++ b/tests/unit/test_cli_ai_skill_memory.py @@ -87,6 +87,7 @@ class TestAiMemorySetCli: @patch("limacharlie.commands.ai_memory.Organization") def test_set_with_content_flag(self, mock_org_cls, mock_client_cls): mock_client = MagicMock() + mock_client.request.return_value = {} mock_client_cls.return_value = mock_client mock_org = MagicMock() mock_org.oid = "test-oid" @@ -114,6 +115,7 @@ def test_set_with_content_flag(self, mock_org_cls, mock_client_cls): @patch("limacharlie.commands.ai_memory.Organization") def test_set_with_stdin(self, mock_org_cls, mock_client_cls): mock_client = MagicMock() + mock_client.request.return_value = {} mock_client_cls.return_value = mock_client mock_org = MagicMock() mock_org.oid = "test-oid" @@ -135,6 +137,7 @@ def test_set_does_not_get_first(self, mock_org_cls, mock_client_cls): """Critical: the CLI must trust the merge hook, not round-trip through GET to fetch the existing record.""" mock_client = MagicMock() + mock_client.request.return_value = {} mock_client_cls.return_value = mock_client mock_org = MagicMock() mock_org.oid = "test-oid" @@ -164,6 +167,7 @@ class TestAiMemoryDeleteCli: @patch("limacharlie.commands.ai_memory.Organization") def test_delete_sends_null_for_named_memory(self, mock_org_cls, mock_client_cls): mock_client = MagicMock() + mock_client.request.return_value = {} mock_client_cls.return_value = mock_client mock_org = MagicMock() mock_org.oid = "test-oid" @@ -193,6 +197,7 @@ def test_delete_requires_confirm(self): def test_delete_record_uses_delete_verb(self, mock_org_cls, mock_client_cls): """delete-record removes the whole agent record, not via the merge.""" mock_client = MagicMock() + mock_client.request.return_value = {} mock_client_cls.return_value = mock_client mock_org = MagicMock() mock_org.oid = "test-oid"