xmemcli 0.0.7__tar.gz → 0.0.8__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xmemcli
3
- Version: 0.0.7
3
+ Version: 0.0.8
4
4
  Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
5
5
  Keywords: api,cli,llm,memory,xmemory
6
6
  Requires-Python: <4,>=3.12
@@ -97,7 +97,7 @@ xmemcli version
97
97
  xmemcli auth login
98
98
  ```
99
99
 
100
- Version: **0.0.7**.
100
+ Version: **0.0.8**.
101
101
 
102
102
 
103
103
  ---
@@ -167,7 +167,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
167
167
 
168
168
  # `xmemcli` — capabilities reference
169
169
 
170
- **Version:** `0.0.7` (see `xmemcli version`)
170
+ **Version:** `0.0.8` (see `xmemcli version`)
171
171
 
172
172
  `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-governed for key acquisition**; an AI-assisted IDE may run reads, writes, and inventory once credentials exist. Humans acquire API keys and change quota limits in the **Console UI**.
173
173
 
@@ -211,6 +211,7 @@ Permission-filtered view of what this API key can see.
211
211
  |---------|-------------|
212
212
  | **`instance get`** `<instance_id>` | Fetch one instance |
213
213
  | **`instance create`** | Create from XMD/JSON schema. Required `--name`; schema via `--schema-file` or `--schema-stdin`. |
214
+ | **`instance instructions`** `[instance_id]` `[text]` `[--clear]` | Read, set or clear the instance's standing instructions to an agent — the owner's own words, carried to every agent that connects it. No text and no `--clear` reads and changes nothing. Setting reads the current value first and writes with the epoch it read, so an edit composed against instructions someone has since replaced is refused rather than applied (the console form and the instance chat write the same field). Clearing is `--clear`; an empty string is refused, because that is what a shell substitutes for an unset variable. The id may be pinned with `--instance-id` or `$XMEM_INSTANCE_ID` and the text given alone — a lone positional that is not an id is read as the text. |
214
215
  | **`instance setup`** `[instance_id]` `[--format agent\|project]` | How to connect this instance to an agent, ordered for where it is likely to be used. Prints the same steps the `get_setup_instructions` MCP tool gives an agent, so a person and the agent beside them read the same thing. `--format project` additionally prints the shared setup files to commit; each teammate still approves the install and signs in once. Carries no credential. |
215
216
 
216
217
  ---
@@ -89,7 +89,7 @@ xmemcli version
89
89
  xmemcli auth login
90
90
  ```
91
91
 
92
- Version: **0.0.7**.
92
+ Version: **0.0.8**.
93
93
 
94
94
 
95
95
  ---
@@ -159,7 +159,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
159
159
 
160
160
  # `xmemcli` — capabilities reference
161
161
 
162
- **Version:** `0.0.7` (see `xmemcli version`)
162
+ **Version:** `0.0.8` (see `xmemcli version`)
163
163
 
164
164
  `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-governed for key acquisition**; an AI-assisted IDE may run reads, writes, and inventory once credentials exist. Humans acquire API keys and change quota limits in the **Console UI**.
165
165
 
@@ -203,6 +203,7 @@ Permission-filtered view of what this API key can see.
203
203
  |---------|-------------|
204
204
  | **`instance get`** `<instance_id>` | Fetch one instance |
205
205
  | **`instance create`** | Create from XMD/JSON schema. Required `--name`; schema via `--schema-file` or `--schema-stdin`. |
206
+ | **`instance instructions`** `[instance_id]` `[text]` `[--clear]` | Read, set or clear the instance's standing instructions to an agent — the owner's own words, carried to every agent that connects it. No text and no `--clear` reads and changes nothing. Setting reads the current value first and writes with the epoch it read, so an edit composed against instructions someone has since replaced is refused rather than applied (the console form and the instance chat write the same field). Clearing is `--clear`; an empty string is refused, because that is what a shell substitutes for an unset variable. The id may be pinned with `--instance-id` or `$XMEM_INSTANCE_ID` and the text given alone — a lone positional that is not an id is read as the text. |
206
207
  | **`instance setup`** `[instance_id]` `[--format agent\|project]` | How to connect this instance to an agent, ordered for where it is likely to be used. Prints the same steps the `get_setup_instructions` MCP tool gives an agent, so a person and the agent beside them read the same thing. `--format project` additionally prints the shared setup files to commit; each teammate still approves the install and signs in once. Carries no credential. |
207
208
 
208
209
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmemcli"
3
- version = "0.0.7"
3
+ version = "0.0.8"
4
4
  description = "Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<4"
@@ -4,5 +4,5 @@ requires-python = ">=3.12, <4"
4
4
 
5
5
  [[package]]
6
6
  name = "xmemcli"
7
- version = "0.0.7"
7
+ version = "0.0.8"
8
8
  source = { editable = "." }
@@ -1,3 +1,3 @@
1
1
  """Standalone xmemcli package (``xmemcli`` console script)."""
2
2
 
3
- __version__ = "0.0.7"
3
+ __version__ = "0.0.8"
@@ -123,6 +123,7 @@ _OVERVIEW_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
123
123
  ("instance get", "Get a single instance by id"),
124
124
  ("instance create", "Create a new instance from an XMD/JSON schema"),
125
125
  ("instance setup", "How to connect an instance to an agent"),
126
+ ("instance instructions", "Read, set or clear an instance's standing instructions to an agent"),
126
127
  ),
127
128
  ),
128
129
  (
@@ -8,6 +8,7 @@ import argparse
8
8
  import json
9
9
  import os
10
10
  import sys
11
+ import uuid
11
12
  from pathlib import Path
12
13
  from typing import Any
13
14
  from urllib.parse import urlencode
@@ -80,6 +81,20 @@ def _resolve_instance_id(args: argparse.Namespace) -> str | None:
80
81
  return os.environ.get(m.ENV_INSTANCE_ID)
81
82
 
82
83
 
84
+ def _looks_like_instance_id(value: str) -> bool:
85
+ """Whether this could be an instance id at all.
86
+
87
+ Used only to tell a misplaced argument from a real id — never to validate one the
88
+ server will check anyway. Deliberately permissive about formatting so a caller who
89
+ strips the dashes is not refused by their own client.
90
+ """
91
+ try:
92
+ uuid.UUID(value)
93
+ except ValueError:
94
+ return False
95
+ return True
96
+
97
+
83
98
  def _require_instance_id(args: argparse.Namespace) -> tuple[str | None, tuple[CliOutput, int] | None]:
84
99
  m = xmem_module()
85
100
  instance_id = _resolve_instance_id(args)
@@ -273,6 +288,128 @@ def cmd_instance_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
273
288
  return (CliOutput(OutputKind.INSTANCE_GET, curate_instance(item, args.verbose)), m.EXIT_OK)
274
289
 
275
290
 
291
+ def cmd_instance_instructions(args: argparse.Namespace) -> tuple[CliOutput, int]:
292
+ """Read, set or clear an instance's standing instructions to an agent.
293
+
294
+ The owner's own words, carried to every agent that connects this instance. Reading
295
+ is the default because that is the safe half: with no text and no ``--clear`` this
296
+ prints what is there and changes nothing.
297
+
298
+ **Setting reads first, then writes with the epoch it read.** The same field is
299
+ editable from the console's instance form and from the instance chat, so a write
300
+ composed against a value someone has since replaced is refused rather than applied.
301
+ Sending the text alone would make this the one editor that silently wins.
302
+
303
+ Clearing is ``--clear`` rather than an empty string. The API's rule is that an
304
+ explicit empty value clears and an omitted one is left alone, which is right for a
305
+ wire format and wrong for a terminal: `instructions <id> ""` is what a shell hands
306
+ over when a variable is unset, and that should not be how a standing rule is
307
+ deleted.
308
+ """
309
+ m = xmem_module()
310
+ api_key, _ = m._require(args, need_instance=False)
311
+ base = m._api_url(args).rstrip("/")
312
+ # Two optional positionals, and argparse fills the first one first — so with the
313
+ # instance pinned by --instance-id or the environment, `instructions "Prefer short
314
+ # answers."` lands the text in the id slot. Left alone that reads a nonsense instance
315
+ # and reports "not found", which looks like a failed lookup rather than what it is: a
316
+ # write that never happened.
317
+ #
318
+ # Reinterpreted rather than refused, because the documented form is
319
+ # `instructions [<id>] [text]` with the id optional, and refusing would have made the
320
+ # optional half unusable for writes — the reader would have to repeat an id they had
321
+ # already pinned. A lone positional that is not an id, when an id is available from
322
+ # elsewhere, is the text.
323
+ text: str | None = getattr(args, "text", None)
324
+ positional = getattr(args, "instance_id_arg", None)
325
+ if positional and text is None and not _looks_like_instance_id(positional):
326
+ fallback = getattr(args, "instance_id", None) or os.environ.get(m.ENV_INSTANCE_ID)
327
+ if not fallback:
328
+ return (
329
+ make_error(
330
+ f"{positional!r} is not an instance id, and none is pinned. Pass the id first "
331
+ "(`instance instructions <id> <text>`), or set --instance-id or "
332
+ f"${m.ENV_INSTANCE_ID} and give the text alone",
333
+ stage="usage",
334
+ ),
335
+ m.EXIT_USAGE,
336
+ )
337
+ text = positional
338
+ args.instance_id_arg = None
339
+
340
+ # The same resolution every instance-scoped command uses: positional, then
341
+ # --instance-id, then the environment.
342
+ instance_id, usage_error = _require_instance_id(args)
343
+ if usage_error is not None:
344
+ return usage_error
345
+
346
+ clearing: bool = bool(getattr(args, "clear", False))
347
+ if text is not None and clearing:
348
+ return (make_error("pass either replacement text or --clear, not both", stage="usage"), m.EXIT_USAGE)
349
+ if text is not None and not text.strip():
350
+ return (
351
+ make_error("refusing to clear from an empty string; use --clear to remove the instructions", stage="usage"),
352
+ m.EXIT_USAGE,
353
+ )
354
+
355
+ url = f"{base}/instances/{instance_id}"
356
+ try:
357
+ current = m._unwrap_items(m._get(url, api_key))
358
+ except m.ApiError as e:
359
+ if e.status == 404:
360
+ return (
361
+ make_error(f"instance {instance_id} not found or not visible to this key", status=404, stage="request"),
362
+ m.EXIT_HTTP_ERROR,
363
+ )
364
+ return http_error_output(e, context="request rejected")
365
+
366
+ if text is None and not clearing:
367
+ return (
368
+ CliOutput(
369
+ OutputKind.INSTANCE_INSTRUCTIONS,
370
+ {
371
+ "instance_id": instance_id,
372
+ "instructions": current.get("agent_owner_instructions"),
373
+ "epoch": current.get("agent_owner_instructions_epoch", 0),
374
+ },
375
+ ),
376
+ m.EXIT_OK,
377
+ )
378
+
379
+ body = {
380
+ "agent_owner_instructions": "" if clearing else text,
381
+ # Read a moment ago, above. The server compares it under the row lock, so this
382
+ # closes the window a client-side "did it change?" check cannot.
383
+ "expected_owner_instructions_epoch": current.get("agent_owner_instructions_epoch", 0),
384
+ }
385
+ try:
386
+ updated = m._unwrap_items(m._patch(url, body, api_key))
387
+ except m.ApiError as e:
388
+ if e.status == 409:
389
+ return (
390
+ make_error(
391
+ "the instructions changed while this command was composing its edit; "
392
+ "read them again and reapply",
393
+ status=409,
394
+ stage="request",
395
+ ),
396
+ m.EXIT_HTTP_ERROR,
397
+ )
398
+ return http_error_output(e, context="update rejected")
399
+
400
+ return (
401
+ CliOutput(
402
+ OutputKind.INSTANCE_INSTRUCTIONS,
403
+ {
404
+ "instance_id": instance_id,
405
+ "instructions": updated.get("agent_owner_instructions"),
406
+ "epoch": updated.get("agent_owner_instructions_epoch", 0),
407
+ },
408
+ ),
409
+ m.EXIT_OK,
410
+ )
411
+
412
+
276
413
  def cmd_instance_setup(args: argparse.Namespace) -> tuple[CliOutput, int]:
277
414
  """Print how to connect an instance on an agent surface.
278
415
 
@@ -152,6 +152,12 @@ class OutputKind(StrEnum):
152
152
  #: agent surface. Commands only; never a credential.
153
153
  INSTANCE_SETUP = "instance_setup"
154
154
 
155
+ #: Output of ``xmemcli instance instructions`` — the instance's standing
156
+ #: instructions to an agent, read or after a write. Curated payload:
157
+ #: ``instance_id``, ``instructions`` (``None`` when none are set) and the
158
+ #: ``epoch`` the value was read at.
159
+ INSTANCE_INSTRUCTIONS = "instance_instructions"
160
+
155
161
  #: Output of ``xmemcli instance create``.
156
162
  INSTANCE_CREATE = "instance_create"
157
163
 
@@ -260,6 +260,30 @@ def _terminal_safe(value: object) -> str:
260
260
  return "".join(ch for ch in text if ch == "\t" or (ch.isprintable() and ch != "\x1b"))
261
261
 
262
262
 
263
+ def _render_instance_instructions(payload: Any, stream: IO[str]) -> None:
264
+ """Print the standing instructions as text, not as a record.
265
+
266
+ A caller reading them almost always wants to pipe or read them, so the text is the
267
+ output and everything else is a header. Unset prints one line saying so rather than
268
+ nothing at all: silence and "the instructions are empty" are the same output
269
+ otherwise, and only one of them means the read worked.
270
+ """
271
+ if not isinstance(payload, dict):
272
+ _default_handler(payload, stream)
273
+ return
274
+ instructions = payload.get("instructions")
275
+ stream.write(f"instructions for {_terminal_safe(payload.get('instance_id', '?'))}\n")
276
+ if not (isinstance(instructions, str) and instructions.strip()):
277
+ stream.write(" (none set)\n")
278
+ return
279
+ # Split first, sanitize per line. `_terminal_safe` drops every unprintable character
280
+ # and a newline is one, so scrubbing the whole value first ran the lines together —
281
+ # and these are authoritative instructions, where joining two bullets or two policy
282
+ # boundaries changes what the reader is told.
283
+ for line in instructions.splitlines() or [""]:
284
+ stream.write(f" {_terminal_safe(line)}\n")
285
+
286
+
263
287
  def _render_instance_setup(payload: Any, stream: IO[str]) -> None:
264
288
  """Print the connect instructions as instructions rather than as a payload.
265
289
 
@@ -684,6 +708,7 @@ _RENDERERS: dict[str, Renderer] = {
684
708
  "instance_create": _render_instance_create,
685
709
  "instance_delete": _render_instance_delete,
686
710
  "instance_setup": _render_instance_setup,
711
+ "instance_instructions": _render_instance_instructions,
687
712
  "schema_update": _render_xmd_schema,
688
713
  "schema_dry_run": _render_schema_dry_run,
689
714
  "schema_migrations": _render_schema_migrations,
@@ -1,4 +1,4 @@
1
1
  """Release metadata for the xmemcli CLI (copied into standalone builds)."""
2
2
 
3
3
  CLI_NAME = "xmemcli"
4
- __version__ = "0.0.7"
4
+ __version__ = "0.0.8"
@@ -104,6 +104,7 @@ from ._instance_schema import ( # noqa: E402 (sibling-script import)
104
104
  cmd_instance_create,
105
105
  cmd_instance_delete,
106
106
  cmd_instance_get,
107
+ cmd_instance_instructions,
107
108
  cmd_instance_setup,
108
109
  cmd_schema_dry_run,
109
110
  cmd_schema_get,
@@ -679,6 +680,17 @@ def _put(url: str, body: dict[str, Any], api_key: str, timeout_s: float = 60.0)
679
680
  return _read_json_response(_build_request(url, "PUT", api_key, body), timeout_s)
680
681
 
681
682
 
683
+ def _patch(url: str, body: dict[str, Any], api_key: str, timeout_s: float = 60.0) -> dict[str, Any]:
684
+ """A partial update, where the *absence* of a field is the meaningful part.
685
+
686
+ Distinct from `_put` for the reason the API distinguishes them: a field this body
687
+ does not mention is left alone, and one it sets to an empty value is cleared. A
688
+ caller that sent the whole record back would wipe a standing instruction merely by
689
+ renaming an instance.
690
+ """
691
+ return _read_json_response(_build_request(url, "PATCH", api_key, body), timeout_s)
692
+
693
+
682
694
  def _delete(url: str, api_key: str, timeout_s: float = 60.0) -> dict[str, Any]:
683
695
  return _read_json_response(_build_request(url, "DELETE", api_key), timeout_s, allow_empty=True)
684
696
 
@@ -1787,6 +1799,30 @@ def _build_parser() -> argparse.ArgumentParser:
1787
1799
  help="'agent' (default) prints the steps to run now; 'project' also prints the shared setup files to commit; each teammate still approves and signs in once",
1788
1800
  )
1789
1801
  pis.set_defaults(func=cmd_instance_setup)
1802
+ pii = pi_sub.add_parser(
1803
+ "instructions",
1804
+ help="Read, set or clear an instance's standing instructions to an agent",
1805
+ add_help=False,
1806
+ )
1807
+ pii.add_argument(
1808
+ "instance_id_arg",
1809
+ metavar="instance_id",
1810
+ nargs="?",
1811
+ help="Instance to read or edit; defaults to --instance-id or $XMEM_INSTANCE_ID",
1812
+ )
1813
+ pii.add_argument(
1814
+ "text",
1815
+ nargs="?",
1816
+ help="Replacement instructions. Omit to read; use --clear to remove them",
1817
+ )
1818
+ pii.add_argument(
1819
+ "--clear",
1820
+ action="store_true",
1821
+ # Deleting a standing rule is its own flag rather than an empty string, which is
1822
+ # what a shell substitutes for an unset variable.
1823
+ help="Remove the instructions. Cannot be combined with replacement text",
1824
+ )
1825
+ pii.set_defaults(func=cmd_instance_instructions)
1790
1826
  pic = pi_sub.add_parser(
1791
1827
  "create",
1792
1828
  help="Create a new instance from an XMD/JSON schema",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes