appmem 0.1.0__py3-none-any.whl

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.
appmem/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """appmem: live terminal view of RAM and swap usage per application."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ # pyproject.toml's [project] version is the single source; read it back from the
6
+ # installed distribution's metadata instead of duplicating it here by hand.
7
+ __version__ = version(__name__)
appmem/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """CLI entry point (SPEC.md "Command line")."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from appmem.cli import main as _main
6
+
7
+
8
+ def main() -> None:
9
+ """Run the CLI and exit with its process exit code."""
10
+ raise SystemExit(_main())
11
+
12
+
13
+ if __name__ == "__main__":
14
+ main()
appmem/cli.py ADDED
@@ -0,0 +1,646 @@
1
+ """CLI: argument parsing, pre-start checks and process exit codes.
2
+
3
+ `snapshot`, `app` and `schema` follow the CLI Design Standard 0.1.0 and
4
+ describe themselves through `appmem schema`; SPEC.md owns the live view. Every parser is built
5
+ from the descriptors in `schema.py`, so a flag or default declared there is
6
+ the one the parser actually accepts: nothing here re-states a flag name,
7
+ help text or default on its own. All pre-start failures print a human line
8
+ to stderr, then one JSON error object as the last stderr line; `main()`
9
+ returns the process exit code rather than calling `sys.exit` itself, so
10
+ tests can call it directly.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import json
18
+ import math
19
+ import os
20
+ import signal
21
+ import sys
22
+ from collections.abc import Callable, Sequence
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+ from typing import Any, NoReturn, Protocol
26
+
27
+ from appmem import __version__, report, schema
28
+ from appmem.collect import CgroupUnavailableError, find_units
29
+ from appmem.render import render_app_text, render_snapshot_text
30
+ from appmem.theme import (
31
+ APPMEM_THEME_ENV,
32
+ TEXTUAL_THEME_ENV,
33
+ THEME_NAMES,
34
+ canonical_theme_name,
35
+ config_path,
36
+ resolve_theme,
37
+ )
38
+ from appmem.ui.app import AppMemApp
39
+
40
+ MIN_INTERVAL = 0.2
41
+
42
+ ERROR_KINDS: tuple[str, ...] = (
43
+ "invalid_input",
44
+ "terminal_required",
45
+ "cgroup_unavailable",
46
+ "not_found",
47
+ "interrupted",
48
+ )
49
+ """Every `kind` an error object can carry. The single source for both the
50
+ runtime check below and `tests/test_docs.py`, so a kind renamed here without
51
+ updating README.md or SKILL.md fails the build instead of drifting quietly."""
52
+
53
+ _HELP_TEXT = """\
54
+ appmem: live terminal view of RAM and swap usage per application, not per process.
55
+
56
+ Usage:
57
+ appmem [-i SECONDS] [--system] [--theme NAME]
58
+ appmem snapshot [--system] [--limit N] [--json]
59
+ appmem app NAME [--scope user|system] [--limit N] [--json]
60
+ appmem schema [COMMAND]
61
+ appmem --help | -h
62
+ appmem --version | -V
63
+
64
+ Flags:
65
+ -i, --interval SECONDS Refresh interval, a number >= 0.2 (default: 1); the live view only
66
+ --system Start with system services shown (same as pressing x)
67
+ --theme NAME One of appmem's theme names (appmem schema lists them; terminal-dark
68
+ and terminal-light use your terminal's own colours); overrides
69
+ APPMEM_THEME and the config file for this run, the live view only,
70
+ never written back
71
+ --json Write JSON instead of text; the default when stdout isn't a terminal
72
+ -h, --help Show this help and exit
73
+ -V, --version Show the version and exit
74
+
75
+ Commands:
76
+ snapshot One sample of the machine and every app
77
+ app NAME One app's units, processes, commands and remainder
78
+ schema Describe the commands, flags, output shapes and exit codes as JSON
79
+
80
+ Run `appmem schema` for the full machine-readable interface, or
81
+ `appmem snapshot --help` (any command works the same way) for that command's own flags.
82
+
83
+ Keys:
84
+ click header sort by that column, click again to reverse
85
+ r / s / t / d / z sort by RAM / SWAP / TOTAL / ΔSWAP / ZSWAP (repeat to reverse;
86
+ z only where the ZSWAP column is shown); other columns sort by click
87
+ up/down PgUp PgDn move
88
+ Enter open the process view for the selected app;
89
+ grouped: the processes of the selected command
90
+ g process view: toggle grouping by command
91
+ Esc back to the main view
92
+ c toggle the CACHE column
93
+ w toggle the ZSWAP column (shown by default where zswap is on)
94
+ x toggle system services
95
+ b reset the Δ baseline to now
96
+ T / Ctrl+P change the theme (opens on the current theme, remembered in the
97
+ config file)
98
+ ? help screen
99
+ q / Ctrl+C quit
100
+
101
+ Memory pressure:
102
+ none few memory stalls in the last 10 s
103
+ some (X.X %) some time spent waiting; shown with the percentage
104
+ high a lot of time spent waiting: memory stalls are happening,
105
+ but this alone doesn't say which app is causing them
106
+ Big swap with pressure none just means idle pages were paged out.
107
+
108
+ Example:
109
+ appmem -i 2 --system
110
+ """
111
+
112
+ _SNAPSHOT_HELP_TEXT = f"""\
113
+ appmem snapshot: {schema.SNAPSHOT_DESCRIPTION}
114
+
115
+ Usage:
116
+ appmem snapshot [--system] [--limit N] [--json]
117
+ appmem snapshot --help | -h
118
+
119
+ Flags:
120
+ --system {schema.SNAPSHOT_SYSTEM.description} (default: false)
121
+ --limit N {schema.SNAPSHOT_LIMIT.description} (default: {schema.SNAPSHOT_LIMIT.default})
122
+ --json {schema.JSON_FLAG.description}
123
+ -h, --help Show this help and exit
124
+
125
+ Text on a terminal, JSON otherwise. `appmem schema snapshot` describes the JSON shape.
126
+ """
127
+
128
+ _APP_HELP_TEXT = f"""\
129
+ appmem app: {schema.APP_DESCRIPTION}
130
+
131
+ Usage:
132
+ appmem app NAME [--scope user|system] [--limit N] [--json]
133
+ appmem app --help | -h
134
+
135
+ Args:
136
+ NAME {schema.APP_NAME.description}
137
+
138
+ Flags:
139
+ --scope VALUE {schema.APP_SCOPE.description} (default: {schema.APP_SCOPE.default})
140
+ --limit N {schema.APP_LIMIT.description} (default: {schema.APP_LIMIT.default})
141
+ --json {schema.JSON_FLAG.description}
142
+ -h, --help Show this help and exit
143
+
144
+ Note: a process's ram_bytes is RSS -- shared pages count once in every process
145
+ that maps it, so don't sum processes; use the app's own ram_bytes instead.
146
+
147
+ Text on a terminal, JSON otherwise. `appmem schema app` describes the JSON shape.
148
+ """
149
+
150
+ _SCHEMA_HELP_TEXT = """\
151
+ appmem schema: describe the commands, flags, output shapes and exit codes as JSON.
152
+
153
+ Usage:
154
+ appmem schema [COMMAND]
155
+ appmem schema --help | -h
156
+
157
+ With no argument, prints the introspection index (every command, the shared
158
+ flags, format defaults and exit codes). With a command name ("snapshot" or
159
+ "app"), prints that command's own flags, defaults and output schema. Always
160
+ writes JSON, with or without --json.
161
+ """
162
+
163
+
164
+ class _SubparserFactory(Protocol):
165
+ """What `_add_*_parser` needs from `ArgumentParser.add_subparsers()`'s
166
+ result: just enough to add one named subparser, without naming argparse's
167
+ own (private) type for it."""
168
+
169
+ def add_parser(self, name: str, **kwargs: Any) -> argparse.ArgumentParser: ...
170
+
171
+
172
+ class _ArgumentParser(argparse.ArgumentParser):
173
+ """Routes argparse's own error path through the standard's JSON error format."""
174
+
175
+ def error(self, message: str) -> NoReturn:
176
+ usage = self.format_usage()
177
+ sys.stderr.write(usage) # fails "with the accepted form"
178
+ accepted = " ".join(usage.removeprefix("usage:").split())
179
+ _fail("invalid_input", message, 2, action="agent", hint=f"Accepted form: {accepted}")
180
+
181
+
182
+ def _make_help_action(text: str) -> type[argparse.Action]:
183
+ """Build a `--help` action that prints a fixed cheat sheet instead of
184
+ argparse's generated help; one per command, since each has its own text."""
185
+
186
+ class _Help(argparse.Action):
187
+ def __init__(
188
+ self,
189
+ option_strings: Sequence[str],
190
+ dest: str = argparse.SUPPRESS,
191
+ default: str = argparse.SUPPRESS,
192
+ help: str | None = None,
193
+ ) -> None:
194
+ super().__init__(
195
+ option_strings=option_strings, dest=dest, default=default, nargs=0, help=help
196
+ )
197
+
198
+ def __call__(
199
+ self,
200
+ parser: argparse.ArgumentParser,
201
+ namespace: argparse.Namespace,
202
+ values: str | Sequence[Any] | None,
203
+ option_string: str | None = None,
204
+ ) -> None:
205
+ print(text, end="")
206
+ parser.exit()
207
+
208
+ return _Help
209
+
210
+
211
+ def _interval(value: str) -> float:
212
+ try:
213
+ parsed = float(value)
214
+ except ValueError as exc:
215
+ raise argparse.ArgumentTypeError(
216
+ f"invalid interval {value!r}: must be a number >= {MIN_INTERVAL}"
217
+ ) from exc
218
+ if not math.isfinite(parsed) or parsed < MIN_INTERVAL:
219
+ raise argparse.ArgumentTypeError(f"invalid interval {value!r}: must be >= {MIN_INTERVAL}")
220
+ return parsed
221
+
222
+
223
+ def _theme_name(value: str) -> str:
224
+ canonical = canonical_theme_name(value)
225
+ if canonical not in THEME_NAMES:
226
+ valid = ", ".join(THEME_NAMES)
227
+ raise argparse.ArgumentTypeError(f"invalid theme {value!r}: must be one of {valid}")
228
+ return canonical
229
+
230
+
231
+ def _positive_int(value: str) -> int:
232
+ try:
233
+ parsed = int(value)
234
+ except ValueError as exc:
235
+ raise argparse.ArgumentTypeError(
236
+ f"invalid limit {value!r}: must be an integer >= 1"
237
+ ) from exc
238
+ if parsed < 1:
239
+ raise argparse.ArgumentTypeError(f"invalid limit {value!r}: must be an integer >= 1")
240
+ return parsed
241
+
242
+
243
+ def _option_strings(flag: schema.Flag) -> list[str]:
244
+ short = [f"-{alias}" for alias in flag.aliases if len(alias) == 1]
245
+ long_aliases = [f"--{alias}" for alias in flag.aliases if len(alias) != 1]
246
+ return [*short, f"--{flag.name}", *long_aliases]
247
+
248
+
249
+ def _add_json_flag(parser: argparse.ArgumentParser, *, suppress: bool) -> None:
250
+ parser.add_argument(
251
+ *_option_strings(schema.JSON_FLAG),
252
+ action="store_true",
253
+ default=argparse.SUPPRESS if suppress else schema.JSON_FLAG.default,
254
+ help=schema.JSON_FLAG.description,
255
+ )
256
+
257
+
258
+ def _build_parser() -> argparse.ArgumentParser:
259
+ parser = _ArgumentParser(prog="appmem", add_help=False)
260
+ parser.add_argument(
261
+ "-h", "--help", action=_make_help_action(_HELP_TEXT), help="show this help and exit"
262
+ )
263
+ parser.add_argument(
264
+ *_option_strings(schema.ROOT_INTERVAL),
265
+ type=_interval,
266
+ default=None, # None means "not given"; a named command rejects an explicit value
267
+ metavar="SECONDS",
268
+ help=schema.ROOT_INTERVAL.description,
269
+ )
270
+ parser.add_argument(
271
+ *_option_strings(schema.ROOT_SYSTEM),
272
+ action="store_true",
273
+ default=schema.ROOT_SYSTEM.default,
274
+ help=schema.ROOT_SYSTEM.description,
275
+ )
276
+ parser.add_argument(
277
+ *_option_strings(schema.ROOT_THEME),
278
+ # Not `choices=`: that would list `ansi-dark`/`ansi-light` (still
279
+ # accepted, see `_theme_name`) in argparse's own usage/error text
280
+ # right alongside the honest names, or reject them outright if left
281
+ # out of `choices` -- `_theme_name` accepts both and only ever
282
+ # reports the canonical `THEME_NAMES` on a real miss.
283
+ type=_theme_name,
284
+ default=None, # None means "not given"; a named command rejects an explicit value
285
+ metavar="NAME",
286
+ help=schema.ROOT_THEME.description,
287
+ )
288
+ _add_json_flag(parser, suppress=False)
289
+ parser.add_argument("-V", "--version", action="version", version=__version__)
290
+
291
+ subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
292
+ _add_snapshot_parser(subparsers)
293
+ _add_app_parser(subparsers)
294
+ _add_schema_parser(subparsers)
295
+ return parser
296
+
297
+
298
+ def _add_snapshot_parser(subparsers: _SubparserFactory) -> None:
299
+ parser = subparsers.add_parser("snapshot", add_help=False)
300
+ parser.add_argument(
301
+ "-h",
302
+ "--help",
303
+ action=_make_help_action(_SNAPSHOT_HELP_TEXT),
304
+ help="show this help and exit",
305
+ )
306
+ parser.add_argument(
307
+ *_option_strings(schema.SNAPSHOT_SYSTEM),
308
+ action="store_true",
309
+ # SUPPRESS: a bare "appmem --system snapshot" must keep the root's value,
310
+ # not have this parser's own default silently overwrite it.
311
+ default=argparse.SUPPRESS,
312
+ help=schema.SNAPSHOT_SYSTEM.description,
313
+ )
314
+ parser.add_argument(
315
+ *_option_strings(schema.SNAPSHOT_LIMIT),
316
+ type=_positive_int,
317
+ default=schema.SNAPSHOT_LIMIT.default,
318
+ metavar="N",
319
+ help=schema.SNAPSHOT_LIMIT.description,
320
+ )
321
+ _add_json_flag(parser, suppress=True)
322
+
323
+
324
+ def _add_app_parser(subparsers: _SubparserFactory) -> None:
325
+ parser = subparsers.add_parser("app", add_help=False)
326
+ parser.add_argument(
327
+ "-h", "--help", action=_make_help_action(_APP_HELP_TEXT), help="show this help and exit"
328
+ )
329
+ parser.add_argument("name", metavar="NAME", help=schema.APP_NAME.description)
330
+ parser.add_argument(
331
+ *_option_strings(schema.APP_SCOPE),
332
+ choices=schema.APP_SCOPE.enum,
333
+ default=schema.APP_SCOPE.default,
334
+ help=schema.APP_SCOPE.description,
335
+ )
336
+ parser.add_argument(
337
+ *_option_strings(schema.APP_LIMIT),
338
+ type=_positive_int,
339
+ default=schema.APP_LIMIT.default,
340
+ metavar="N",
341
+ help=schema.APP_LIMIT.description,
342
+ )
343
+ _add_json_flag(parser, suppress=True)
344
+
345
+
346
+ def _add_schema_parser(subparsers: _SubparserFactory) -> None:
347
+ parser = subparsers.add_parser("schema", add_help=False)
348
+ parser.add_argument(
349
+ "-h", "--help", action=_make_help_action(_SCHEMA_HELP_TEXT), help="show this help and exit"
350
+ )
351
+ parser.add_argument(
352
+ "path",
353
+ nargs="?",
354
+ default=None,
355
+ metavar="COMMAND",
356
+ help="command to describe; omit for the index",
357
+ )
358
+ _add_json_flag(parser, suppress=True)
359
+
360
+
361
+ def _print_error_json(
362
+ kind: str,
363
+ message: str,
364
+ *,
365
+ action: str | None = None,
366
+ hint: str | None = None,
367
+ next_argv: list[str] | None = None,
368
+ ) -> None:
369
+ assert kind in ERROR_KINDS, f"undeclared error kind: {kind!r}"
370
+ print(f"appmem: {message}", file=sys.stderr)
371
+ error: dict[str, object] = {"kind": kind, "message": message}
372
+ if action is not None:
373
+ error["action"] = action
374
+ if hint is not None:
375
+ error["hint"] = hint
376
+ if next_argv is not None:
377
+ error["next"] = next_argv
378
+ print(json.dumps({"error": error}), file=sys.stderr)
379
+
380
+
381
+ def _fail(
382
+ kind: str,
383
+ message: str,
384
+ code: int,
385
+ *,
386
+ action: str | None = None,
387
+ hint: str | None = None,
388
+ next_argv: list[str] | None = None,
389
+ ) -> NoReturn:
390
+ _print_error_json(kind, message, action=action, hint=hint, next_argv=next_argv)
391
+ raise SystemExit(code)
392
+
393
+
394
+ def _write_result(text: str) -> None:
395
+ """Print a command's result. If a downstream reader has already closed
396
+ the pipe, stop writing and exit 0 instead of letting the interpreter's
397
+ own shutdown print a 'Broken pipe' traceback."""
398
+ try:
399
+ sys.stdout.write(text + "\n")
400
+ sys.stdout.flush()
401
+ except BrokenPipeError:
402
+ devnull = os.open(os.devnull, os.O_WRONLY)
403
+ os.dup2(devnull, sys.stdout.fileno())
404
+ raise SystemExit(0) from None
405
+
406
+
407
+ def _run_app(app: AppMemApp) -> int:
408
+ """Run the Textual app, translating an external SIGTERM/SIGINT into a graceful exit.
409
+
410
+ Ctrl+C typed in the running TUI never reaches these handlers: Textual's raw
411
+ terminal mode stops the terminal from turning it into a signal in the first
412
+ place, so it arrives as an ordinary key event instead (SPEC.md "Errors").
413
+
414
+ Registered with `loop.add_signal_handler` rather than `signal.signal`: a
415
+ `signal.signal` handler only runs the next time the event loop wakes up on
416
+ its own, so `app.exit()` could sit unapplied until the next periodic tick;
417
+ `add_signal_handler` wakes the loop immediately via its self-pipe instead.
418
+ Requires an explicit loop, created and closed here (`loop.close()` also
419
+ removes the handlers registered on it), instead of the implicit one
420
+ `app.run()` would otherwise create.
421
+ """
422
+
423
+ def _handle_sigterm() -> None:
424
+ app.exit(return_code=143)
425
+
426
+ def _handle_sigint() -> None:
427
+ app.exit(return_code=130)
428
+
429
+ loop = asyncio.new_event_loop()
430
+ loop.add_signal_handler(signal.SIGTERM, _handle_sigterm)
431
+ loop.add_signal_handler(signal.SIGINT, _handle_sigint)
432
+ try:
433
+ app.run(loop=loop)
434
+ finally:
435
+ loop.close()
436
+ # A screen recorded a vanished cgroup tree (SPEC.md "Errors"): the JSON
437
+ # line prints here, after Textual has restored the terminal, reusing the
438
+ # same error-printing path as the pre-start checks.
439
+ if app.cgroup_error_message is not None:
440
+ _print_error_json("cgroup_unavailable", app.cgroup_error_message, action="user")
441
+ return 1
442
+ return app.return_code if app.return_code is not None else 0
443
+
444
+
445
+ def _run_root(
446
+ args: argparse.Namespace,
447
+ *,
448
+ root: Path,
449
+ uid: int,
450
+ stdin_isatty: Callable[[], bool],
451
+ stdout_isatty: Callable[[], bool],
452
+ ) -> int:
453
+ """Bare `appmem`: the live view, but only in a terminal context."""
454
+ json_flag = bool(args.json)
455
+ no_input = bool(os.environ.get("NO_INPUT"))
456
+ if not (stdin_isatty() and stdout_isatty()) or json_flag or no_input:
457
+ next_argv = ["appmem", "snapshot"]
458
+ if json_flag:
459
+ next_argv.append("--json")
460
+ # A context failure, not a usage error: exit 1 alongside cgroup_unavailable
461
+ # and not_found, so exit 2 stays reserved for a real invalid_input call.
462
+ _fail(
463
+ "terminal_required",
464
+ "the live view needs a terminal; for a one-shot report run: appmem snapshot",
465
+ 1,
466
+ action="agent",
467
+ hint="Run appmem snapshot for a one-shot report",
468
+ next_argv=next_argv,
469
+ )
470
+
471
+ try:
472
+ find_units(root, uid, include_system=args.system)
473
+ except CgroupUnavailableError as exc:
474
+ _fail("cgroup_unavailable", str(exc), 1, action="user")
475
+
476
+ interval = args.interval if args.interval is not None else 1.0
477
+ theme = resolve_theme(
478
+ cli_theme=args.theme,
479
+ env_theme=os.environ.get(APPMEM_THEME_ENV) or None, # empty means unset, as NO_INPUT
480
+ config_path=config_path(),
481
+ textual_theme=os.environ.get(TEXTUAL_THEME_ENV) or None,
482
+ )
483
+ app = AppMemApp(
484
+ root=root,
485
+ uid=uid,
486
+ interval=interval,
487
+ include_system=args.system,
488
+ theme=theme.effective,
489
+ config_theme=theme.config_theme,
490
+ theme_warnings=theme.warnings,
491
+ )
492
+ return _run_app(app)
493
+
494
+
495
+ def _run_snapshot(
496
+ args: argparse.Namespace, *, root: Path, uid: int, stdout_isatty: Callable[[], bool]
497
+ ) -> int:
498
+ json_flag = bool(args.json)
499
+ include_system = bool(args.system)
500
+ now = datetime.now().astimezone()
501
+ try:
502
+ document, total_apps = report.snapshot_document(
503
+ root, uid, include_system=include_system, limit=args.limit, now=now
504
+ )
505
+ except KeyboardInterrupt:
506
+ _fail("interrupted", "interrupted while reading the snapshot", 130, action="user")
507
+ except CgroupUnavailableError as exc:
508
+ _fail("cgroup_unavailable", str(exc), 1, action="user")
509
+
510
+ next_argv = document.get("next")
511
+ if json_flag and next_argv:
512
+ next_argv.append("--json")
513
+
514
+ if json_flag or not stdout_isatty():
515
+ _write_result(json.dumps(document))
516
+ else:
517
+ _write_result(render_snapshot_text(document, total_apps=total_apps))
518
+ return 0
519
+
520
+
521
+ def _run_app_command(
522
+ args: argparse.Namespace, *, root: Path, uid: int, stdout_isatty: Callable[[], bool]
523
+ ) -> int:
524
+ json_flag = bool(args.json)
525
+ now = datetime.now().astimezone()
526
+ try:
527
+ document, total_processes, total_commands = report.app_document(
528
+ root, uid, args.name, args.scope, limit=args.limit, now=now
529
+ )
530
+ except KeyboardInterrupt:
531
+ _fail("interrupted", "interrupted while reading the app", 130, action="user")
532
+ except CgroupUnavailableError as exc:
533
+ _fail("cgroup_unavailable", str(exc), 1, action="user")
534
+ except report.AppNotFoundError:
535
+ next_argv = ["appmem", "snapshot"]
536
+ if args.scope == "system":
537
+ next_argv.append("--system")
538
+ if json_flag:
539
+ next_argv.append("--json")
540
+ _fail(
541
+ "not_found",
542
+ f"no app named {args.name!r} in scope {args.scope!r}",
543
+ 1,
544
+ action="agent",
545
+ hint="Names are as listed by appmem snapshot; system services need --scope system",
546
+ next_argv=next_argv,
547
+ )
548
+
549
+ if json_flag or not stdout_isatty():
550
+ _write_result(json.dumps(document))
551
+ else:
552
+ _write_result(
553
+ render_app_text(
554
+ document, total_processes=total_processes, total_commands=total_commands
555
+ )
556
+ )
557
+ return 0
558
+
559
+
560
+ def _run_schema(args: argparse.Namespace) -> int:
561
+ path: list[str] = [] if args.path is None else [args.path]
562
+ if not path:
563
+ document = schema.index()
564
+ else:
565
+ found = schema.detail(path)
566
+ if found is None:
567
+ _fail(
568
+ "invalid_input",
569
+ f"unknown schema path {' '.join(path)!r}",
570
+ 2,
571
+ action="agent",
572
+ hint="Run appmem schema for the command index",
573
+ next_argv=["appmem", "schema"],
574
+ )
575
+ document = found
576
+ _write_result(json.dumps(document))
577
+ return 0
578
+
579
+
580
+ def _reject_live_view_flags(args: argparse.Namespace) -> None:
581
+ """Root flags belong to the live view; a named command that has no such
582
+ flag of its own must refuse them rather than silently drop them."""
583
+ if args.command is not None and args.interval is not None:
584
+ _fail(
585
+ "invalid_input",
586
+ "--interval applies to the live view; a named command takes one sample and ignores it",
587
+ 2,
588
+ action="agent",
589
+ hint="Drop -i/--interval, or run appmem with no command for the live view",
590
+ )
591
+ if args.command in ("app", "schema") and args.system:
592
+ _fail(
593
+ "invalid_input",
594
+ f"--system applies to the live view and snapshot, not to {args.command}",
595
+ 2,
596
+ action="agent",
597
+ hint="Drop --system; for a system service run appmem app NAME --scope system",
598
+ )
599
+ if args.command is not None and args.theme is not None:
600
+ _fail(
601
+ "invalid_input",
602
+ "--theme applies to the live view; a named command has nothing to colour",
603
+ 2,
604
+ action="agent",
605
+ hint="Drop --theme, or run appmem with no command for the live view",
606
+ )
607
+
608
+
609
+ def main(
610
+ argv: Sequence[str] | None = None,
611
+ *,
612
+ root: Path | None = None,
613
+ uid: int | None = None,
614
+ stdin_isatty: Callable[[], bool] = lambda: sys.stdin.isatty(),
615
+ stdout_isatty: Callable[[], bool] = lambda: sys.stdout.isatty(),
616
+ ) -> int:
617
+ """Parse args, dispatch to a command, and return its process exit code.
618
+
619
+ Returns the exit code instead of calling `sys.exit` so tests (and
620
+ `--help`/`--version`/usage errors, which raise `SystemExit` from
621
+ argparse) stay easy to drive directly.
622
+ """
623
+ args = _build_parser().parse_args(argv)
624
+ resolved_root = Path("/") if root is None else root
625
+ resolved_uid = os.getuid() if uid is None else uid
626
+
627
+ _reject_live_view_flags(args)
628
+
629
+ if args.command == "snapshot":
630
+ return _run_snapshot(
631
+ args, root=resolved_root, uid=resolved_uid, stdout_isatty=stdout_isatty
632
+ )
633
+ if args.command == "app":
634
+ return _run_app_command(
635
+ args, root=resolved_root, uid=resolved_uid, stdout_isatty=stdout_isatty
636
+ )
637
+ if args.command == "schema":
638
+ return _run_schema(args)
639
+
640
+ return _run_root(
641
+ args,
642
+ root=resolved_root,
643
+ uid=resolved_uid,
644
+ stdin_isatty=stdin_isatty,
645
+ stdout_isatty=stdout_isatty,
646
+ )