ocmo-cli 0.8.19__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.
Files changed (81) hide show
  1. LICENSE +201 -0
  2. NOTICE +16 -0
  3. ocmo_cli/__init__.py +3 -0
  4. ocmo_cli/__main__.py +4 -0
  5. ocmo_cli/_address.py +182 -0
  6. ocmo_cli/_audit_item.py +33 -0
  7. ocmo_cli/_auth_resolve.py +66 -0
  8. ocmo_cli/_body_payload.py +203 -0
  9. ocmo_cli/_click_groups.py +227 -0
  10. ocmo_cli/_client.py +351 -0
  11. ocmo_cli/_client_commands.py +35 -0
  12. ocmo_cli/_command_registry.py +29 -0
  13. ocmo_cli/_commands_map.py +65 -0
  14. ocmo_cli/_config.py +183 -0
  15. ocmo_cli/_config_content.py +43 -0
  16. ocmo_cli/_delete_item_output.py +129 -0
  17. ocmo_cli/_diff_input.py +144 -0
  18. ocmo_cli/_dry_run.py +292 -0
  19. ocmo_cli/_errors.py +151 -0
  20. ocmo_cli/_exit.py +13 -0
  21. ocmo_cli/_export_metadata.py +47 -0
  22. ocmo_cli/_generated_registry.py +45 -0
  23. ocmo_cli/_get_cast_output.py +52 -0
  24. ocmo_cli/_get_item_list.py +23 -0
  25. ocmo_cli/_globalpermission_output.py +98 -0
  26. ocmo_cli/_import_metadata.py +140 -0
  27. ocmo_cli/_import_plan.py +221 -0
  28. ocmo_cli/_item_output.py +223 -0
  29. ocmo_cli/_lazy_mixin.py +29 -0
  30. ocmo_cli/_ls_wide.py +286 -0
  31. ocmo_cli/_mutating.py +39 -0
  32. ocmo_cli/_notice.py +17 -0
  33. ocmo_cli/_options.py +77 -0
  34. ocmo_cli/_output.py +424 -0
  35. ocmo_cli/_output_manifest.py +332 -0
  36. ocmo_cli/_resolve_draft.py +50 -0
  37. ocmo_cli/_resolve_options.py +143 -0
  38. ocmo_cli/_resolve_output.py +228 -0
  39. ocmo_cli/_resolve_parameters_output.py +72 -0
  40. ocmo_cli/_resolve_series.py +89 -0
  41. ocmo_cli/_resolve_series_chart.py +409 -0
  42. ocmo_cli/_resolve_write.py +185 -0
  43. ocmo_cli/_resolver_output.py +34 -0
  44. ocmo_cli/_resource_aliases.py +22 -0
  45. ocmo_cli/_sdk_dispatch.py +310 -0
  46. ocmo_cli/_typing.py +27 -0
  47. ocmo_cli/_version_output.py +39 -0
  48. ocmo_cli/_whoami_output.py +43 -0
  49. ocmo_cli/commands/__init__.py +1 -0
  50. ocmo_cli/commands/api.py +125 -0
  51. ocmo_cli/commands/api_health.py +12 -0
  52. ocmo_cli/commands/apply.py +153 -0
  53. ocmo_cli/commands/auth.py +786 -0
  54. ocmo_cli/commands/can_i.py +51 -0
  55. ocmo_cli/commands/completion.py +59 -0
  56. ocmo_cli/commands/config_cmd.py +235 -0
  57. ocmo_cli/commands/describe.py +109 -0
  58. ocmo_cli/commands/diff.py +86 -0
  59. ocmo_cli/commands/edit.py +200 -0
  60. ocmo_cli/commands/export.py +242 -0
  61. ocmo_cli/commands/generated.py +907 -0
  62. ocmo_cli/commands/gp_move.py +108 -0
  63. ocmo_cli/commands/import_.py +595 -0
  64. ocmo_cli/commands/item_relocate.py +187 -0
  65. ocmo_cli/commands/ls.py +481 -0
  66. ocmo_cli/commands/resolve.py +658 -0
  67. ocmo_cli/commands/resolve_draft.py +102 -0
  68. ocmo_cli/commands/resolve_parameters.py +65 -0
  69. ocmo_cli/commands/resolve_series.py +109 -0
  70. ocmo_cli/commands/schema.py +112 -0
  71. ocmo_cli/commands/timeline.py +135 -0
  72. ocmo_cli/commands/version_cmd.py +102 -0
  73. ocmo_cli/commands/whoami.py +12 -0
  74. ocmo_cli/main.py +241 -0
  75. ocmo_cli/output_formats.yaml +295 -0
  76. ocmo_cli-0.8.19.dist-info/METADATA +169 -0
  77. ocmo_cli-0.8.19.dist-info/RECORD +81 -0
  78. ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
  79. ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
  80. ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
  81. ocmo_cli-0.8.19.dist-info/licenses/NOTICE +16 -0
@@ -0,0 +1,102 @@
1
+ """ocmo resolve draft — resolve unsaved config YAML without persisting it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from .._address import parse_simple_address_or_exit, reject_version
11
+ from .._client import OcmoCtx
12
+ from .._errors import sdk_command
13
+ from .._options import namespace_option
14
+ from .._resolve_draft import call_resolve_draft
15
+ from .._resolve_options import resolve_options
16
+
17
+ _DRAFT_HELP = """\
18
+ Resolve unsaved draft YAML at ADDRESS without persisting it.
19
+
20
+ Uses the same output, cast, parameter, filesystem, and hook options as
21
+ ``ocmo resolve``. Draft content is read from ``-f/--file`` (or stdin via ``-``).
22
+
23
+ \b
24
+ Examples:
25
+ ocmo -n prod resolve draft app/web -f draft.yaml
26
+ ocmo -n prod resolve draft app/web -f - --cast json < draft.yaml
27
+ ocmo -n prod resolve draft app/web -f draft.yaml --trace-only -o yaml
28
+ """
29
+
30
+
31
+ @click.command("draft", help=_DRAFT_HELP)
32
+ @click.argument("address")
33
+ @namespace_option()
34
+ @resolve_options(include_version=False, file_required=True)
35
+ @click.pass_obj
36
+ @sdk_command
37
+ def resolve_draft_cmd(
38
+ ctx: OcmoCtx,
39
+ address: str,
40
+ namespace: str | None,
41
+ output_fmt: str | None,
42
+ cast: str | None,
43
+ params: tuple[str, ...],
44
+ cast_options: tuple[str, ...],
45
+ output_file: str | None,
46
+ output_dir: str | None,
47
+ rewrite: bool,
48
+ skip_existing: bool,
49
+ trace_only: bool,
50
+ prop_path: str | None,
51
+ exec_hooks: bool,
52
+ hook_timeout: int,
53
+ trust_hooks_sha: str | None,
54
+ print_hooks: bool,
55
+ file_path: str,
56
+ ) -> None:
57
+ from .resolve import _parse_kv_args, _print_hooks, run_resolve_pipeline
58
+
59
+ path, version = parse_simple_address_or_exit(address)
60
+ reject_version(version, command="resolve draft")
61
+
62
+ content = sys.stdin.read() if file_path == "-" else Path(file_path).read_text()
63
+
64
+ params_dict = _parse_kv_args(params)
65
+ cast_options_dict = _parse_kv_args(cast_options)
66
+
67
+ view = ctx.namespace_view(namespace)
68
+ ns = ctx.require_namespace(namespace)
69
+
70
+ if print_hooks:
71
+ trace_only = True
72
+
73
+ result = call_resolve_draft(
74
+ view,
75
+ path,
76
+ content=content,
77
+ cast=cast,
78
+ trace_only=trace_only,
79
+ params=params_dict or None,
80
+ cast_options=cast_options_dict or None,
81
+ )
82
+
83
+ if print_hooks:
84
+ _print_hooks(result)
85
+ return
86
+
87
+ run_resolve_pipeline(
88
+ ctx,
89
+ result,
90
+ path=path,
91
+ ns=ns,
92
+ output_fmt=output_fmt,
93
+ output_file=output_file,
94
+ output_dir=output_dir,
95
+ rewrite=rewrite,
96
+ skip_existing=skip_existing,
97
+ trace_only=trace_only,
98
+ prop_path=prop_path,
99
+ exec_hooks=exec_hooks,
100
+ hook_timeout=hook_timeout,
101
+ trust_hooks_sha=trust_hooks_sha,
102
+ )
@@ -0,0 +1,65 @@
1
+ """ocmo resolve parameters — effective parameter values for a config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from .._address import parse_address_or_exit
10
+ from .._client import OcmoCtx
11
+ from .._errors import sdk_command
12
+ from .._options import namespace_option, output_option
13
+ from .._output_manifest import emit_command_output
14
+ from .._resolve_parameters_output import filter_parameters_data
15
+
16
+ _COMMAND_KEY = "resolve parameters"
17
+
18
+ _PARAMETER_TYPE_CHOICES = ("projected", "dynamic", "secret")
19
+
20
+ _PARAMETERS_HELP = """\
21
+ Show effective parameter values for a config (debug).
22
+
23
+ \b
24
+ Examples:
25
+ ocmo -n prod resolve parameters app/web
26
+ ocmo -n prod resolve parameters app/web@stable -o yaml
27
+ ocmo -n prod resolve parameters app/web --type secret
28
+ ocmo -n prod resolve parameters app/web --type projected --type dynamic
29
+ """
30
+
31
+
32
+ @click.command("parameters", help=_PARAMETERS_HELP)
33
+ @click.argument("address")
34
+ @namespace_option()
35
+ @output_option(_COMMAND_KEY)
36
+ @click.option("--version", "-V", "version_flag", default=None, help="Version / tag to resolve.")
37
+ @click.option(
38
+ "--type",
39
+ "param_types",
40
+ multiple=True,
41
+ type=click.Choice(_PARAMETER_TYPE_CHOICES, case_sensitive=False),
42
+ help="Filter parameters by type (repeatable).",
43
+ )
44
+ @click.pass_obj
45
+ @sdk_command
46
+ def resolve_parameters_cmd(
47
+ ctx: OcmoCtx,
48
+ address: str,
49
+ namespace: str | None,
50
+ output_fmt: str | None,
51
+ version_flag: str | None,
52
+ param_types: tuple[str, ...],
53
+ ) -> None:
54
+ path, version = parse_address_or_exit(address, version_flag=version_flag)
55
+
56
+ view = ctx.namespace_view(namespace)
57
+
58
+ kwargs: dict[str, Any] = {}
59
+ if version:
60
+ kwargs["version"] = version
61
+
62
+ result = view.resolve_parameters(path, **kwargs)
63
+ data = filter_parameters_data(result, param_types)
64
+
65
+ emit_command_output(_COMMAND_KEY, data, output_fmt, ctx_fmt=ctx.output)
@@ -0,0 +1,109 @@
1
+ """ocmo resolve-series audit — time-bucketed resolve statistics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from .._audit_item import resolve_audit_item
8
+ from .._client import OcmoCtx
9
+ from .._errors import sdk_command
10
+ from .._exit import USAGE_ERROR
11
+ from .._options import namespace_option, output_option
12
+ from .._output import emit, err
13
+ from .._output_manifest import emit_command_output, get_command_spec, resolve_effective_format
14
+ from .._resolve_series import resolve_bucket_seconds, resolve_time_range, series_to_dict
15
+ from .._resolve_series_chart import print_resolve_series_chart
16
+
17
+ _COMMAND_KEY = "resolve-series audit"
18
+
19
+ _AUDIT_HELP = """\
20
+ Show time-bucketed direct, nested, and error resolve counts for a tree item.
21
+
22
+ \b
23
+ Examples:
24
+ ocmo -n prod resolve-series audit app/web
25
+ ocmo -n prod resolve-series audit app/web -o json
26
+ ocmo -n prod resolve-series audit app/web --from 2026-07-01T00:00:00Z
27
+ """
28
+
29
+
30
+ @click.group("resolve-series", help="Resolve statistics over time.")
31
+ def resolve_series_group() -> None:
32
+ """Resolve-series commands."""
33
+
34
+
35
+ @resolve_series_group.command("audit", help=_AUDIT_HELP)
36
+ @click.argument("address")
37
+ @click.option(
38
+ "--from",
39
+ "from_value",
40
+ default=None,
41
+ metavar="ISO",
42
+ help="Range start (ISO-8601 UTC). Default: 30 days before --to.",
43
+ )
44
+ @click.option(
45
+ "--to",
46
+ "to_value",
47
+ default=None,
48
+ metavar="ISO",
49
+ help="Range end (ISO-8601 UTC). Default: now.",
50
+ )
51
+ @click.option(
52
+ "--bucket-seconds",
53
+ type=int,
54
+ default=None,
55
+ help="Bucket size in seconds (minimum 1800). Default: auto from range.",
56
+ )
57
+ @namespace_option()
58
+ @output_option(_COMMAND_KEY)
59
+ @click.pass_obj
60
+ @sdk_command
61
+ def resolve_series_audit_cmd(
62
+ ctx: OcmoCtx,
63
+ address: str,
64
+ from_value: str | None,
65
+ to_value: str | None,
66
+ bucket_seconds: int | None,
67
+ namespace: str | None,
68
+ output_fmt: str | None,
69
+ ) -> None:
70
+ view, path, node_type = resolve_audit_item(
71
+ ctx,
72
+ address,
73
+ namespace,
74
+ command="resolve-series audit",
75
+ )
76
+
77
+ try:
78
+ start, end = resolve_time_range(from_value, to_value)
79
+ bucket = resolve_bucket_seconds(start, end, override=bucket_seconds)
80
+ except ValueError as exc:
81
+ err(str(exc))
82
+ raise SystemExit(USAGE_ERROR) from exc
83
+
84
+ result = view.namespace_audit_resolve_series(
85
+ object_id=path,
86
+ object_type=node_type,
87
+ from_=start,
88
+ to=end,
89
+ bucket_seconds=bucket,
90
+ )
91
+ data = series_to_dict(result)
92
+
93
+ spec = get_command_spec(_COMMAND_KEY)
94
+ effective_fmt = resolve_effective_format(output_fmt, ctx.output, spec)
95
+
96
+ if effective_fmt == "chart":
97
+ print_resolve_series_chart(
98
+ data,
99
+ node_type=node_type,
100
+ range_start=start,
101
+ range_end=end,
102
+ )
103
+ return
104
+
105
+ if effective_fmt in ("json", "yaml"):
106
+ emit(data, effective_fmt)
107
+ return
108
+
109
+ emit_command_output(_COMMAND_KEY, data, output_fmt, ctx_fmt=ctx.output)
@@ -0,0 +1,112 @@
1
+ """ocmo schema — render JSON Schemas for OCMO metadata and resource configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from .._client import OcmoCtx
12
+ from .._errors import sdk_command
13
+ from .._options import namespace_option, output_option
14
+ from .._output import as_dict
15
+ from .._output_manifest import emit_command_output
16
+
17
+ _VALID_RESOURCES = "ocmo, config, resolver"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class _SchemaSpec:
22
+ aliases: tuple[str, ...]
23
+ requires_address: bool
24
+ forbids_address: bool
25
+ usage_error: str
26
+ fetch: Callable[[OcmoCtx, str | None, str | None], Any]
27
+
28
+
29
+ def _fetch_ocmo_schema(ctx: OcmoCtx, _namespace: str | None, _address: str | None) -> Any:
30
+ return ctx.client().get_config_metadata_schema() # type: ignore[no-untyped-call]
31
+
32
+
33
+ def _fetch_resolver_schema(ctx: OcmoCtx, _namespace: str | None, _address: str | None) -> Any:
34
+ return ctx.client().get_resolver_configuration_schema() # type: ignore[no-untyped-call]
35
+
36
+
37
+ def _fetch_config_schema(ctx: OcmoCtx, namespace: str | None, address: str | None) -> Any:
38
+ if not address:
39
+ raise click.UsageError(
40
+ "ADDRESS is required for RESOURCE 'config'. " "Use: ocmo -n <namespace> schema config <path>"
41
+ )
42
+ return ctx.namespace_view(namespace).get_config_data_schema(path=address)
43
+
44
+
45
+ _SCHEMA_DISPATCH: dict[str, _SchemaSpec] = {
46
+ "ocmo": _SchemaSpec(
47
+ aliases=("ocmo",),
48
+ requires_address=False,
49
+ forbids_address=True,
50
+ usage_error="ADDRESS is not used with RESOURCE 'ocmo'. Use: ocmo schema ocmo",
51
+ fetch=_fetch_ocmo_schema,
52
+ ),
53
+ "config": _SchemaSpec(
54
+ aliases=("config", "cfg", "configs"),
55
+ requires_address=True,
56
+ forbids_address=False,
57
+ usage_error="",
58
+ fetch=_fetch_config_schema,
59
+ ),
60
+ "resolver": _SchemaSpec(
61
+ aliases=("resolver", "rsv", "resolvers"),
62
+ requires_address=False,
63
+ forbids_address=True,
64
+ usage_error="ADDRESS is not used with RESOURCE 'resolver'. Use: ocmo schema resolver",
65
+ fetch=_fetch_resolver_schema,
66
+ ),
67
+ }
68
+
69
+ _SCHEMA_LOOKUP: dict[str, _SchemaSpec] = {alias: spec for spec in _SCHEMA_DISPATCH.values() for alias in spec.aliases}
70
+
71
+
72
+ @click.command("schema", help="Render the JSON Schema for a resource type.")
73
+ @click.argument("resource_type", metavar="RESOURCE", required=False)
74
+ @click.argument("address", required=False)
75
+ @namespace_option()
76
+ @output_option("schema")
77
+ @click.pass_obj
78
+ @sdk_command
79
+ def schema_cmd(
80
+ ctx: OcmoCtx,
81
+ resource_type: str | None,
82
+ address: str | None,
83
+ namespace: str | None,
84
+ output_fmt: str | None,
85
+ ) -> None:
86
+ """Render the JSON Schema for a resource type.
87
+
88
+ \b
89
+ Resource types:
90
+ ocmo — ``_ocmo`` metadata block schema
91
+ config / cfg — config data schema (requires ADDRESS and -n)
92
+ resolver / rsv — resolver configuration schema
93
+
94
+ \b
95
+ Examples:
96
+ ocmo schema ocmo # _ocmo block schema
97
+ ocmo -n prod schema config app/web # data schema for a specific config
98
+ ocmo schema resolver # resolver configuration schema
99
+ """
100
+ if not resource_type:
101
+ raise click.UsageError(f"RESOURCE type is required. Valid types: {_VALID_RESOURCES}")
102
+
103
+ spec = _SCHEMA_LOOKUP.get(resource_type.lower())
104
+ if spec is None:
105
+ raise click.UsageError(f"Unknown resource type {resource_type!r}. Valid: {_VALID_RESOURCES}")
106
+
107
+ if spec.forbids_address and address:
108
+ raise click.UsageError(spec.usage_error)
109
+
110
+ schema = spec.fetch(ctx, namespace, address)
111
+ data = as_dict(schema)
112
+ emit_command_output("schema", data, output_fmt, ctx_fmt=ctx.output)
@@ -0,0 +1,135 @@
1
+ """ocmo timeline audit — item modification history as human-readable notes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import click
9
+
10
+ from .._audit_item import resolve_audit_item
11
+ from .._client import OcmoCtx
12
+ from .._errors import sdk_command
13
+ from .._exit import USAGE_ERROR
14
+ from .._options import namespace_option
15
+ from .._output import as_dict, err, format_datetime
16
+
17
+ if TYPE_CHECKING:
18
+ from ocmo import NamespaceView
19
+
20
+ _TIMELINE_PAGE_SIZE = 100
21
+
22
+ _AUDIT_HELP = """\
23
+ Show modification history for a tree item as human-readable notes.
24
+
25
+ \b
26
+ Examples:
27
+ ocmo -n prod timeline audit app/web
28
+ ocmo -n prod timeline audit app/web --search stable
29
+ ocmo -n prod timeline audit app/web --limit 10
30
+ """
31
+
32
+
33
+ def _reject_output_format(ctx: OcmoCtx) -> None:
34
+ if ctx.output:
35
+ raise click.UsageError("timeline audit does not support -o/--output or OCMO_OUTPUT for this command.")
36
+
37
+
38
+ def _fetch_timeline_entries(
39
+ view: NamespaceView,
40
+ *,
41
+ path: str,
42
+ node_type: str,
43
+ search: str | None,
44
+ limit: int | None = None,
45
+ ) -> list[Any]:
46
+ entries: list[Any] = []
47
+ offset = 0
48
+ while True:
49
+ if limit is not None:
50
+ remaining = limit - len(entries)
51
+ if remaining <= 0:
52
+ break
53
+ page_limit = min(_TIMELINE_PAGE_SIZE, remaining)
54
+ else:
55
+ page_limit = _TIMELINE_PAGE_SIZE
56
+
57
+ page = view.namespace_audit_timeline(
58
+ object_id=path,
59
+ object_type=node_type,
60
+ search=search,
61
+ limit=page_limit,
62
+ offset=offset,
63
+ )
64
+ items = list(getattr(page, "items", []) or [])
65
+ entries.extend(items)
66
+ if limit is not None and len(entries) >= limit:
67
+ return entries[:limit]
68
+ total = int(getattr(page, "count", len(entries)))
69
+ if not items or offset + len(items) >= total:
70
+ break
71
+ offset += len(items)
72
+ return entries
73
+
74
+
75
+ def _entry_message(entry: Any) -> tuple[str, str]:
76
+ data = as_dict(entry)
77
+ occurred_at = format_datetime(data.get("occurred_at"))
78
+ message = str(data.get("message") or "").strip()
79
+ return occurred_at, message
80
+
81
+
82
+ @click.group("timeline", help="Item-scoped audit timelines.")
83
+ def timeline_group() -> None:
84
+ """Timeline commands."""
85
+
86
+
87
+ @timeline_group.command("audit", help=_AUDIT_HELP)
88
+ @click.argument("address")
89
+ @click.option("--search", default=None, help="Filter notes (matches audit timeline search).")
90
+ @click.option("--limit", type=int, default=None, help="Maximum number of timeline events to show.")
91
+ @namespace_option()
92
+ @click.pass_obj
93
+ @sdk_command
94
+ def timeline_audit_cmd(
95
+ ctx: OcmoCtx,
96
+ address: str,
97
+ search: str | None,
98
+ limit: int | None,
99
+ namespace: str | None,
100
+ ) -> None:
101
+ _reject_output_format(ctx)
102
+
103
+ view, path, node_type = resolve_audit_item(
104
+ ctx,
105
+ address,
106
+ namespace,
107
+ command="timeline audit",
108
+ allow_version_flag=True,
109
+ )
110
+
111
+ if limit is not None and limit < 1:
112
+ err("--limit must be a positive integer.")
113
+ raise SystemExit(USAGE_ERROR)
114
+
115
+ entries = _fetch_timeline_entries(
116
+ view,
117
+ path=path,
118
+ node_type=node_type,
119
+ search=search,
120
+ limit=limit,
121
+ )
122
+
123
+ if not entries:
124
+ if sys.stdout.isatty():
125
+ print("No audit events found.")
126
+ return
127
+
128
+ for entry in entries:
129
+ occurred_at, message = _entry_message(entry)
130
+ if occurred_at and message:
131
+ print(f"{occurred_at} {message}")
132
+ elif message:
133
+ print(message)
134
+ elif occurred_at:
135
+ print(occurred_at)
@@ -0,0 +1,102 @@
1
+ """ocmo version — print CLI, SDK, and server versions with a compatibility verdict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from .. import __version__ as CLI_VERSION
10
+ from .._client import OcmoCtx
11
+ from .._errors import sdk_command
12
+ from .._exit import FAILURE
13
+ from .._output import as_dict
14
+
15
+
16
+ @click.command("version")
17
+ @click.option(
18
+ "--skip-server", is_flag=True, default=False, help="Print CLI and SDK versions only; do not contact the server."
19
+ )
20
+ @click.option("--notice", is_flag=True, default=False, help="Print the bundled product NOTICE text.")
21
+ @click.pass_obj
22
+ @sdk_command
23
+ def version_cmd(ctx: OcmoCtx, skip_server: bool, notice: bool) -> None:
24
+ """Print CLI, SDK, and server versions with a compatibility verdict."""
25
+ try:
26
+ import ocmo as _sdk
27
+
28
+ sdk_version = _sdk.__version__
29
+ except Exception:
30
+ sdk_version = "unknown"
31
+
32
+ if notice:
33
+ try:
34
+ from .._notice import load_notice_text
35
+
36
+ notice_text = load_notice_text()
37
+ except Exception as exc:
38
+ click.echo(f"Failed to load NOTICE: {exc}", err=True)
39
+ sys.exit(FAILURE)
40
+ print(notice_text, end="" if notice_text.endswith("\n") else "\n")
41
+ return
42
+
43
+ if skip_server:
44
+ _print_versions(CLI_VERSION, sdk_version, server_version="(skipped)", verdict="")
45
+ return
46
+
47
+ server_version = "(unavailable)"
48
+ verdict = ""
49
+ try:
50
+ info = ctx.client().version() # type: ignore[no-untyped-call]
51
+ data = as_dict(info)
52
+ server_version = data.get("version", "(unknown)")
53
+ verdict = _compatibility_verdict(sdk_version, server_version)
54
+ except SystemExit:
55
+ raise
56
+ except Exception as exc:
57
+ err_str = str(exc)
58
+ if "OCMO_SERVER" in err_str or "required" in err_str.lower():
59
+ server_version = "(server not configured)"
60
+ else:
61
+ server_version = f"(error: {exc})"
62
+ verdict = ""
63
+
64
+ _print_versions(CLI_VERSION, sdk_version, server_version, verdict)
65
+
66
+ if "MAJOR MISMATCH" in verdict:
67
+ sys.exit(FAILURE)
68
+
69
+
70
+ def _print_versions(cli: str, sdk: str, server_version: str, verdict: str) -> None:
71
+ print(f"ocmo CLI: {cli}")
72
+ print(f"ocmo SDK: {sdk}")
73
+ print(f"Server: {server_version}")
74
+ if verdict:
75
+ print(f"Compat: {verdict}")
76
+
77
+
78
+ def _compatibility_verdict(sdk_version: str, server_version: str) -> str:
79
+ sdk_parts = _parse_semver(sdk_version)
80
+ srv_parts = _parse_semver(server_version)
81
+ if not sdk_parts or not srv_parts:
82
+ return "WARNING: could not parse one or both versions"
83
+ sdk_major, sdk_minor, _ = sdk_parts
84
+ srv_major, srv_minor, _ = srv_parts
85
+ if sdk_major != srv_major:
86
+ return f"MAJOR MISMATCH — SDK major {sdk_major} != server major {srv_major} (not compatible)"
87
+ diff = abs(sdk_minor - srv_minor)
88
+ if diff > 2:
89
+ return (
90
+ f"WARNING: minor version gap {diff} exceeds the ±2 compatibility window "
91
+ f"(SDK {sdk_version}, server {server_version})"
92
+ )
93
+ return "OK"
94
+
95
+
96
+ def _parse_semver(v: str) -> tuple[int, int, int] | None:
97
+ import re
98
+
99
+ m = re.match(r"^(\d+)\.(\d+)\.(\d+)", v)
100
+ if not m:
101
+ return None
102
+ return int(m.group(1)), int(m.group(2)), int(m.group(3))
@@ -0,0 +1,12 @@
1
+ """ocmo whoami — show current identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .._client_commands import client_command
6
+
7
+ whoami_cmd = client_command(
8
+ "whoami",
9
+ help="Show the identity of the current authenticated principal.",
10
+ manifest_key="whoami",
11
+ method="whoami",
12
+ )