arctx-cli 0.2.0b2__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 (49) hide show
  1. arctx_cli/__init__.py +1 -0
  2. arctx_cli/alias.py +238 -0
  3. arctx_cli/append_batch.py +90 -0
  4. arctx_cli/commands/__init__.py +85 -0
  5. arctx_cli/commands/alias_cmd.py +174 -0
  6. arctx_cli/commands/anchor.py +82 -0
  7. arctx_cli/commands/current.py +69 -0
  8. arctx_cli/commands/cut.py +89 -0
  9. arctx_cli/commands/dump.py +72 -0
  10. arctx_cli/commands/ext.py +236 -0
  11. arctx_cli/commands/git.py +216 -0
  12. arctx_cli/commands/graph.py +73 -0
  13. arctx_cli/commands/guide.py +360 -0
  14. arctx_cli/commands/init.py +223 -0
  15. arctx_cli/commands/list.py +45 -0
  16. arctx_cli/commands/migrate.py +135 -0
  17. arctx_cli/commands/node.py +55 -0
  18. arctx_cli/commands/outcomes.py +58 -0
  19. arctx_cli/commands/payload.py +192 -0
  20. arctx_cli/commands/reachable.py +75 -0
  21. arctx_cli/commands/show.py +113 -0
  22. arctx_cli/commands/sync.py +244 -0
  23. arctx_cli/commands/trace.py +46 -0
  24. arctx_cli/commands/transition.py +212 -0
  25. arctx_cli/commands/use.py +67 -0
  26. arctx_cli/commands/view.py +82 -0
  27. arctx_cli/commands/work_session.py +330 -0
  28. arctx_cli/context.py +38 -0
  29. arctx_cli/ext/__init__.py +1 -0
  30. arctx_cli/ext/command/__init__.py +110 -0
  31. arctx_cli/ext/git/__init__.py +1 -0
  32. arctx_cli/ext/git/branch.py +140 -0
  33. arctx_cli/ext/git/cherry_pick.py +144 -0
  34. arctx_cli/ext/git/commit.py +205 -0
  35. arctx_cli/ext/git/hook.py +758 -0
  36. arctx_cli/ext/git/merge.py +204 -0
  37. arctx_cli/ext/git/reset.py +138 -0
  38. arctx_cli/ext/git/revert.py +157 -0
  39. arctx_cli/ext/git/verify.py +140 -0
  40. arctx_cli/ext/git/worktree.py +173 -0
  41. arctx_cli/ext_registry.py +34 -0
  42. arctx_cli/main.py +133 -0
  43. arctx_cli/paths.py +27 -0
  44. arctx_cli/payload_builder.py +23 -0
  45. arctx_cli/workspace.py +64 -0
  46. arctx_cli-0.2.0b2.dist-info/METADATA +48 -0
  47. arctx_cli-0.2.0b2.dist-info/RECORD +49 -0
  48. arctx_cli-0.2.0b2.dist-info/WHEEL +4 -0
  49. arctx_cli-0.2.0b2.dist-info/entry_points.txt +2 -0
arctx_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """arctx CLI package."""
arctx_cli/alias.py ADDED
@@ -0,0 +1,238 @@
1
+ """Alias resolution for arctx CLI.
2
+
3
+ Resolution priority (later wins for the same key when merging):
4
+ 1. Extension default_aliases (load order; first ext wins among ext-default conflicts)
5
+ 2. User config (~/.config/arctx/aliases.toml)
6
+ 3. Run-local config (<run_dir>/aliases.toml)
7
+
8
+ Alias expansion is one-level only — alias-to-alias chains are prohibited to
9
+ prevent infinite loops.
10
+
11
+ Format of aliases.toml::
12
+
13
+ [aliases]
14
+ commit = "git commit"
15
+ c = "git commit"
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ try:
25
+ import tomllib # py311+ stdlib
26
+ except ModuleNotFoundError: # py310 fallback
27
+ try:
28
+ import tomli as tomllib # type: ignore[no-redef]
29
+ except ModuleNotFoundError as exc:
30
+ raise ImportError(
31
+ "Python 3.10 requires the 'tomli' package for TOML parsing. "
32
+ "Install it with: pip install tomli"
33
+ ) from exc
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Path helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def _user_alias_path() -> Path:
42
+ """Return ``~/.config/arctx/aliases.toml``."""
43
+ xdg = os.environ.get("XDG_CONFIG_HOME")
44
+ if xdg:
45
+ return Path(xdg) / "arctx" / "aliases.toml"
46
+ return Path.home() / ".config" / "arctx" / "aliases.toml"
47
+
48
+
49
+ def _run_alias_path(run_dir: Path | str) -> Path:
50
+ """Return ``<run_dir>/aliases.toml``."""
51
+ return Path(run_dir) / "aliases.toml"
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # TOML writer (minimal — stdlib has no tomllib writer)
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def _write_toml_aliases(path: Path, aliases: dict[str, str]) -> None:
60
+ """Write *aliases* to *path* as a ``[aliases]`` TOML section.
61
+
62
+ Entries are sorted for stable output.
63
+ """
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ lines = ["[aliases]\n"]
66
+ for key in sorted(aliases.keys()):
67
+ value = aliases[key].replace("\\", "\\\\").replace('"', '\\"')
68
+ lines.append(f'{key} = "{value}"\n')
69
+ path.write_text("".join(lines), encoding="utf-8")
70
+
71
+
72
+ def _read_toml_aliases(path: Path) -> dict[str, str]:
73
+ """Read the ``[aliases]`` table from *path*. Returns {} if missing."""
74
+ if not path.exists():
75
+ return {}
76
+ with path.open("rb") as fh:
77
+ data = tomllib.load(fh)
78
+ raw = data.get("aliases", {})
79
+ return {str(k): str(v) for k, v in raw.items()}
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Public API
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def load_alias_table(
88
+ *,
89
+ run_dir: str | Path | None = None,
90
+ extensions_default_aliases: Optional[list[dict[str, str]]] = None,
91
+ ) -> dict[str, str]:
92
+ """Build the merged alias table.
93
+
94
+ Priority (later entries win for the same key):
95
+ - Extension defaults (in load order; first ext wins for ext-level conflicts)
96
+ - User config (~/.config/arctx/aliases.toml)
97
+ - Run-local config (<run_dir>/aliases.toml)
98
+
99
+ Parameters
100
+ ----------
101
+ run_dir:
102
+ Directory of the active run. When provided, ``<run_dir>/aliases.toml``
103
+ is loaded and takes highest priority.
104
+ extensions_default_aliases:
105
+ List of ``default_aliases()`` dicts from enabled extensions, in load
106
+ order. First ext wins for duplicate alias names at this tier.
107
+ """
108
+ merged: dict[str, str] = {}
109
+
110
+ # 1. Extension defaults (first ext wins → iterate in order, skip if already set)
111
+ if extensions_default_aliases:
112
+ for ext_aliases in extensions_default_aliases:
113
+ for name, target in ext_aliases.items():
114
+ if name not in merged:
115
+ merged[name] = target
116
+
117
+ # 2. User config (overrides ext defaults)
118
+ user_aliases = _read_toml_aliases(_user_alias_path())
119
+ merged.update(user_aliases)
120
+
121
+ # 3. Run-local config (highest priority)
122
+ if run_dir is not None:
123
+ run_aliases = _read_toml_aliases(_run_alias_path(run_dir))
124
+ merged.update(run_aliases)
125
+
126
+ return merged
127
+
128
+
129
+ def resolve_alias(alias_table: dict[str, str], tokens: list[str]) -> list[str]:
130
+ """Expand *tokens[0]* if it appears in *alias_table*; else return *tokens*.
131
+
132
+ Expansion is one-level only. The alias value is split on whitespace (no
133
+ shell quoting support needed for now) and prepended to the remaining tokens.
134
+
135
+ Examples
136
+ --------
137
+ >>> resolve_alias({"commit": "git commit"}, ["commit", "-m", "x"])
138
+ ["git", "commit", "-m", "x"]
139
+ >>> resolve_alias({}, ["init", "req"])
140
+ ["init", "req"]
141
+ """
142
+ if not tokens:
143
+ return tokens
144
+ first = tokens[0]
145
+ if first not in alias_table:
146
+ return tokens
147
+ expansion = alias_table[first].split()
148
+ return expansion + tokens[1:]
149
+
150
+
151
+ def save_user_alias(name: str, target: str) -> Path:
152
+ """Add or update *name → target* in the user aliases.toml.
153
+
154
+ Returns the path of the written file.
155
+ """
156
+ path = _user_alias_path()
157
+ existing = _read_toml_aliases(path)
158
+ existing[name] = target
159
+ _write_toml_aliases(path, existing)
160
+ return path
161
+
162
+
163
+ def remove_user_alias(name: str) -> Path:
164
+ """Remove *name* from the user aliases.toml.
165
+
166
+ Returns the path of the written file.
167
+
168
+ Raises
169
+ ------
170
+ KeyError
171
+ If *name* is not present.
172
+ """
173
+ path = _user_alias_path()
174
+ existing = _read_toml_aliases(path)
175
+ if name not in existing:
176
+ raise KeyError(f"alias not found: {name!r}")
177
+ del existing[name]
178
+ _write_toml_aliases(path, existing)
179
+ return path
180
+
181
+
182
+ def list_aliases(
183
+ *,
184
+ run_dir: str | Path | None = None,
185
+ extensions_default_aliases: Optional[list[dict[str, str]]] = None,
186
+ extension_names: Optional[list[str]] = None,
187
+ ) -> dict[str, tuple[str, str]]:
188
+ """Return ``{alias_name: (target, source)}`` with provenance.
189
+
190
+ *source* is one of:
191
+
192
+ - ``"run"`` — from ``<run_dir>/aliases.toml``
193
+ - ``"user"`` — from ``~/.config/arctx/aliases.toml``
194
+ - ``"ext:<name>"`` — from an extension's ``default_aliases()``
195
+
196
+ The same merge priority applies; this function exposes the winning source
197
+ for each alias name.
198
+
199
+ Parameters
200
+ ----------
201
+ extension_names:
202
+ Parallel list to *extensions_default_aliases* giving the extension name
203
+ for each entry. If None, sources are labelled ``"ext:0"``, ``"ext:1"``
204
+ etc.
205
+ """
206
+ result: dict[str, tuple[str, str]] = {}
207
+
208
+ ext_aliases_list = extensions_default_aliases or []
209
+ ext_names_list = extension_names or []
210
+
211
+ # 1. Extension defaults (first ext wins)
212
+ for idx, ext_aliases in enumerate(ext_aliases_list):
213
+ ext_label = f"ext:{ext_names_list[idx]}" if idx < len(ext_names_list) else f"ext:{idx}"
214
+ for name, target in ext_aliases.items():
215
+ if name not in result:
216
+ result[name] = (target, ext_label)
217
+
218
+ # 2. User config
219
+ user_aliases = _read_toml_aliases(_user_alias_path())
220
+ for name, target in user_aliases.items():
221
+ result[name] = (target, "user")
222
+
223
+ # 3. Run-local (highest priority)
224
+ if run_dir is not None:
225
+ run_aliases = _read_toml_aliases(_run_alias_path(run_dir))
226
+ for name, target in run_aliases.items():
227
+ result[name] = (target, "run")
228
+
229
+ return result
230
+
231
+
232
+ __all__ = [
233
+ "load_alias_table",
234
+ "resolve_alias",
235
+ "save_user_alias",
236
+ "remove_user_alias",
237
+ "list_aliases",
238
+ ]
@@ -0,0 +1,90 @@
1
+ """Helpers for turning in-memory mutations into SQLite append batches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from arctx.core.append import AppendBatch, GraphRecordEnvelope
6
+
7
+
8
+ def graph_counts(handle) -> dict[str, set[str]]:
9
+ """Capture graph record IDs before a mutation."""
10
+ return {
11
+ "nodes": set(handle.run_graph.nodes),
12
+ "transitions": set(handle.run_graph.transitions),
13
+ "payloads": set(handle.run_graph.payloads),
14
+ "views": {view.view_id for view in handle.run_graph.views.values()},
15
+ "work_events": {event.event_id for event in handle.run_graph.work_events},
16
+ }
17
+
18
+
19
+ def maybe_append_or_save(
20
+ *,
21
+ store,
22
+ handle,
23
+ user_id: str | None,
24
+ work_session_id: str | None,
25
+ before: dict[str, set[str]],
26
+ ) -> None:
27
+ """Use append_batch for capable stores, otherwise fall back to save_run."""
28
+ if user_id is None or work_session_id is None or not hasattr(store, "append_batch"):
29
+ store.save_run(handle)
30
+ return
31
+ store.append_batch(
32
+ build_append_batch(
33
+ handle,
34
+ user_id=user_id,
35
+ work_session_id=work_session_id,
36
+ before=before,
37
+ )
38
+ )
39
+
40
+
41
+ def build_append_batch(
42
+ handle,
43
+ *,
44
+ user_id: str,
45
+ work_session_id: str,
46
+ before: dict[str, set[str]],
47
+ ) -> AppendBatch:
48
+ """Build an append batch from records added since *before*."""
49
+ records: list[GraphRecordEnvelope] = []
50
+
51
+ for node_id in _new_ids(handle.run_graph.nodes, before, "nodes"):
52
+ node = handle.run_graph.nodes[node_id]
53
+ records.append(GraphRecordEnvelope("node", node.node_id, node))
54
+
55
+ for transition_id in _new_ids(handle.run_graph.transitions, before, "transitions"):
56
+ transition = handle.run_graph.transitions[transition_id]
57
+ records.append(GraphRecordEnvelope("transition", transition.transition_id, transition))
58
+
59
+ for payload_id in _new_ids(handle.run_graph.payloads, before, "payloads"):
60
+ payload = handle.run_graph.payloads[payload_id]
61
+ records.append(GraphRecordEnvelope("payload", payload.payload_id, payload))
62
+
63
+ before_view_ids = before.get("views", set())
64
+ new_views = [
65
+ view for view in handle.run_graph.views.values() if view.view_id not in before_view_ids
66
+ ]
67
+ for view in new_views:
68
+ records.append(GraphRecordEnvelope("view", view.view_id, view))
69
+
70
+ new_events = [
71
+ event
72
+ for event in handle.run_graph.work_events
73
+ if event.event_id not in before.get("work_events", set())
74
+ ]
75
+ if not new_events:
76
+ raise RuntimeError("append batch requires at least one work event")
77
+
78
+ session = handle.run_graph.work_sessions[work_session_id]
79
+ return AppendBatch(
80
+ run_id=handle.run_id,
81
+ user_id=user_id,
82
+ work_session_id=work_session_id,
83
+ work_session=session,
84
+ events=tuple(new_events),
85
+ records=tuple(records),
86
+ )
87
+
88
+
89
+ def _new_ids(current: dict[str, object], before: dict[str, set[str]], key: str) -> list[str]:
90
+ return [record_id for record_id in current if record_id not in before.get(key, set())]
@@ -0,0 +1,85 @@
1
+ """Core CLI command registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from arctx.ext import CliCommand
6
+
7
+
8
+ def core_cli_commands() -> list[CliCommand]:
9
+ """Return the built-in, extension-independent CLI commands."""
10
+ from arctx_cli.commands.alias_cmd import add_parser as add_alias_parser
11
+ from arctx_cli.commands.alias_cmd import cli_alias
12
+ from arctx_cli.commands.anchor import add_parser as add_anchor_parser
13
+ from arctx_cli.commands.anchor import cli_anchor
14
+ from arctx_cli.commands.current import add_parser as add_current_parser
15
+ from arctx_cli.commands.current import cli_current
16
+ from arctx_cli.commands.cut import add_parser as add_cut_parser
17
+ from arctx_cli.commands.cut import cli_cut
18
+ from arctx_cli.commands.dump import add_parser as add_dump_parser
19
+ from arctx_cli.commands.dump import cli_dump
20
+ from arctx_cli.commands.ext import add_parser as add_ext_parser
21
+ from arctx_cli.commands.ext import cli_ext
22
+ from arctx_cli.commands.graph import add_parser as add_graph_parser
23
+ from arctx_cli.commands.graph import cli_graph
24
+ from arctx_cli.commands.guide import add_parser as add_guide_parser
25
+ from arctx_cli.commands.guide import cli_guide
26
+ from arctx_cli.commands.init import add_parser as add_init_parser
27
+ from arctx_cli.commands.init import cli_init
28
+ from arctx_cli.commands.list import add_parser as add_list_parser
29
+ from arctx_cli.commands.list import cli_list
30
+ from arctx_cli.commands.migrate import add_parser as add_migrate_parser
31
+ from arctx_cli.commands.migrate import cli_migrate
32
+ from arctx_cli.commands.node import add_parser as add_node_parser
33
+ from arctx_cli.commands.node import cli_node
34
+ from arctx_cli.commands.outcomes import add_parser as add_outcomes_parser
35
+ from arctx_cli.commands.outcomes import cli_outcomes
36
+ from arctx_cli.commands.payload import add_parser as add_payload_parser
37
+ from arctx_cli.commands.payload import cli_payload
38
+ from arctx_cli.commands.reachable import add_parser as add_reachable_parser
39
+ from arctx_cli.commands.reachable import cli_reachable
40
+ from arctx_cli.commands.show import add_parser as add_show_parser
41
+ from arctx_cli.commands.show import cli_show
42
+ from arctx_cli.commands.sync import add_parser as add_sync_parser
43
+ from arctx_cli.commands.sync import cli_sync
44
+ from arctx_cli.commands.trace import add_parser as add_trace_parser
45
+ from arctx_cli.commands.trace import cli_trace
46
+ from arctx_cli.commands.transition import add_parser as add_transition_parser
47
+ from arctx_cli.commands.transition import cli_transition
48
+ from arctx_cli.commands.use import add_parser as add_use_parser
49
+ from arctx_cli.commands.use import cli_use
50
+ from arctx_cli.commands.view import add_parser as add_view_parser
51
+ from arctx_cli.commands.view import cli_view
52
+ from arctx_cli.commands.work_session import add_parser as add_work_session_parser
53
+ from arctx_cli.commands.work_session import cli_work_session
54
+
55
+ return [
56
+ CliCommand("alias", add_alias_parser, cli_alias),
57
+ CliCommand("anchor", add_anchor_parser, cli_anchor),
58
+ CliCommand("current", add_current_parser, cli_current),
59
+ CliCommand("ext", add_ext_parser, cli_ext),
60
+ CliCommand("dump", add_dump_parser, cli_dump),
61
+ CliCommand("graph", add_graph_parser, cli_graph),
62
+ CliCommand("guide", add_guide_parser, cli_guide),
63
+ CliCommand("init", add_init_parser, cli_init),
64
+ CliCommand("list", add_list_parser, cli_list),
65
+ CliCommand("migrate", add_migrate_parser, cli_migrate),
66
+ CliCommand("node", add_node_parser, cli_node),
67
+ CliCommand("outcomes", add_outcomes_parser, cli_outcomes),
68
+ CliCommand("payload", add_payload_parser, cli_payload),
69
+ CliCommand("reachable", add_reachable_parser, cli_reachable),
70
+ CliCommand("cut", add_cut_parser, cli_cut),
71
+ CliCommand("show", add_show_parser, cli_show),
72
+ CliCommand("sync", add_sync_parser, cli_sync),
73
+ CliCommand("trace", add_trace_parser, cli_trace),
74
+ CliCommand("transition", add_transition_parser, cli_transition),
75
+ CliCommand("use", add_use_parser, cli_use),
76
+ CliCommand("view", add_view_parser, cli_view),
77
+ CliCommand("work-session", add_work_session_parser, cli_work_session),
78
+ ]
79
+
80
+
81
+ def register_cli_commands(subparsers, commands: list[CliCommand]) -> None:
82
+ """Register CLI commands and attach their dispatch handlers."""
83
+ for command in commands:
84
+ parser = command.add_parser(subparsers)
85
+ parser.set_defaults(_arctx_handler=command.handler)
@@ -0,0 +1,174 @@
1
+ """arctx alias subcommand — manage CLI aliases.
2
+
3
+ Subcommands
4
+ -----------
5
+ list Show the current alias table with provenance (run > user > ext).
6
+ add Add or update an alias in the user config.
7
+ remove Remove an alias from the user config.
8
+ resolve Show the expansion of a given alias token (debug helper).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+
17
+
18
+ def add_parser(subparsers) -> argparse.ArgumentParser:
19
+ """Register the ``alias`` subcommand."""
20
+ parser = subparsers.add_parser("alias", help="Manage CLI aliases")
21
+ alias_sub = parser.add_subparsers(dest="alias_command", required=True)
22
+
23
+ # alias list
24
+ al_list = alias_sub.add_parser("list", help="Show all aliases with provenance")
25
+ al_list.add_argument("--run", default=None, help="Run ID")
26
+ al_list.add_argument("--store-dir", default=None, dest="store_dir")
27
+
28
+ # alias add <name> <target>
29
+ al_add = alias_sub.add_parser("add", help="Add or update a user alias")
30
+ al_add.add_argument("name", help="Alias name (e.g. 'c')")
31
+ al_add.add_argument("target", help="Alias target (e.g. 'git commit')")
32
+
33
+ # alias remove <name>
34
+ al_remove = alias_sub.add_parser("remove", help="Remove a user alias")
35
+ al_remove.add_argument("name", help="Alias name to remove")
36
+
37
+ # alias resolve <name>
38
+ al_resolve = alias_sub.add_parser("resolve", help="Show how a token resolves")
39
+ al_resolve.add_argument("name", help="Token to resolve")
40
+ al_resolve.add_argument("--run", default=None, help="Run ID")
41
+ al_resolve.add_argument("--store-dir", default=None, dest="store_dir")
42
+
43
+ return parser
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Helpers
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def _resolve_run_dir_from_args(args) -> str | None:
52
+ """Best-effort: resolve run_dir from args."""
53
+ from pathlib import Path
54
+
55
+ store_dir = getattr(args, "store_dir", None)
56
+ run_id_arg = getattr(args, "run", None)
57
+ if run_id_arg is None:
58
+ import os
59
+
60
+ run_id_arg = os.environ.get("ARCTX_RUN_ID")
61
+ if run_id_arg and store_dir:
62
+ return str(Path(store_dir) / run_id_arg)
63
+ if run_id_arg:
64
+ from arctx_cli.paths import resolve_store_dir
65
+
66
+ return str(Path(resolve_store_dir()) / run_id_arg)
67
+ return None
68
+
69
+
70
+ def _collect_ext_aliases(run_dir: str | None) -> tuple[list[dict[str, str]], list[str]]:
71
+ """Load default_aliases from standard and enabled extensions."""
72
+ from arctx.ext import load_extension
73
+ from arctx.ext.enabled import load_enabled
74
+
75
+ ext_aliases: list[dict[str, str]] = []
76
+ ext_names: list[str] = []
77
+ seen: set[str] = set()
78
+ for ext_name in ["git"]:
79
+ try:
80
+ ext = load_extension(ext_name)
81
+ ext_aliases.append(ext.default_aliases())
82
+ ext_names.append(ext.name)
83
+ seen.add(ext.name)
84
+ except (KeyError, ImportError):
85
+ continue
86
+
87
+ if run_dir is None:
88
+ return ext_aliases, ext_names
89
+
90
+ for ee in load_enabled(run_dir):
91
+ if ee.name in seen:
92
+ continue
93
+ try:
94
+ ext = load_extension(ee.name)
95
+ ext_aliases.append(ext.default_aliases())
96
+ ext_names.append(ext.name)
97
+ seen.add(ext.name)
98
+ except (KeyError, ImportError):
99
+ continue
100
+ return ext_aliases, ext_names
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # CLI dispatcher
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def cli_alias(args) -> int:
109
+ """Dispatch arctx alias subcommands."""
110
+ from arctx_cli.alias import list_aliases, remove_user_alias, resolve_alias, save_user_alias
111
+
112
+ cmd = args.alias_command
113
+
114
+ if cmd == "list":
115
+ run_dir = _resolve_run_dir_from_args(args)
116
+ ext_aliases, ext_names = _collect_ext_aliases(run_dir)
117
+ table = list_aliases(
118
+ run_dir=run_dir,
119
+ extensions_default_aliases=ext_aliases,
120
+ extension_names=ext_names,
121
+ )
122
+ output = {
123
+ name: {"target": target, "source": source}
124
+ for name, (target, source) in table.items()
125
+ }
126
+ print(json.dumps(output, indent=2, ensure_ascii=False, sort_keys=True))
127
+ return 0
128
+
129
+ if cmd == "add":
130
+ path = save_user_alias(args.name, args.target)
131
+ print(f"alias {args.name!r} -> {args.target!r} saved to {path}", file=sys.stderr)
132
+ return 0
133
+
134
+ if cmd == "remove":
135
+ try:
136
+ path = remove_user_alias(args.name)
137
+ except KeyError as exc:
138
+ print(f"error: {exc}", file=sys.stderr)
139
+ return 1
140
+ print(f"alias {args.name!r} removed from {path}", file=sys.stderr)
141
+ return 0
142
+
143
+ if cmd == "resolve":
144
+ run_dir = _resolve_run_dir_from_args(args)
145
+ ext_aliases, _ = _collect_ext_aliases(run_dir)
146
+ from arctx_cli.alias import load_alias_table
147
+
148
+ table = load_alias_table(run_dir=run_dir, extensions_default_aliases=ext_aliases)
149
+ expanded = resolve_alias(table, [args.name])
150
+ if expanded == [args.name]:
151
+ result = {"input": args.name, "resolved": False, "tokens": expanded}
152
+ else:
153
+ source = "unknown"
154
+ from arctx_cli.alias import list_aliases as _la
155
+
156
+ ext_aliases2, ext_names2 = _collect_ext_aliases(run_dir)
157
+ provenance = _la(
158
+ run_dir=run_dir,
159
+ extensions_default_aliases=ext_aliases2,
160
+ extension_names=ext_names2,
161
+ )
162
+ if args.name in provenance:
163
+ source = provenance[args.name][1]
164
+ result = {
165
+ "input": args.name,
166
+ "resolved": True,
167
+ "tokens": expanded,
168
+ "target": table[args.name],
169
+ "source": source,
170
+ }
171
+ print(json.dumps(result, indent=2, ensure_ascii=False))
172
+ return 0
173
+
174
+ return 1
@@ -0,0 +1,82 @@
1
+ """arctx CLI anchor command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+
8
+ from arctx_cli.context import (
9
+ resolve_run_id_from_args,
10
+ resolve_store,
11
+ resolve_user_id_from_args,
12
+ resolve_work_session_id_from_args,
13
+ )
14
+ from arctx_cli.append_batch import graph_counts, maybe_append_or_save
15
+
16
+
17
+ def add_parser(subparsers) -> argparse.ArgumentParser:
18
+ parser = subparsers.add_parser(
19
+ "anchor",
20
+ help="Create a scope anchor node from an existing node",
21
+ description=(
22
+ "Create a lightweight branching anchor using a scope_refinement plan "
23
+ "and a completed result."
24
+ ),
25
+ )
26
+ parser.add_argument("--run", default=None)
27
+ parser.add_argument("--from", required=True, dest="from_node_id", metavar="NODE_ID")
28
+ parser.add_argument("--label", required=True)
29
+ parser.add_argument("--store-dir", default=None)
30
+ parser.add_argument("--user", default=None)
31
+ parser.add_argument("--work-session", default=None)
32
+ return parser
33
+
34
+
35
+ def run_anchor_command(
36
+ *,
37
+ run_id: str,
38
+ from_node_id: str,
39
+ label: str,
40
+ store_dir: str,
41
+ user_id: str | None = None,
42
+ work_session_id: str | None = None,
43
+ ) -> dict:
44
+ store = resolve_store(store_dir)
45
+ if not store.run_path(run_id).exists():
46
+ raise KeyError(f"unknown run_id: {run_id}")
47
+ handle = store.load_run(run_id)
48
+ before = graph_counts(handle)
49
+ node = handle.anchor(
50
+ from_node_id,
51
+ label,
52
+ user_id=user_id,
53
+ work_session_id=work_session_id,
54
+ )
55
+ transition_id = handle.run_graph.transitions_to_node(node.node_id)[0]
56
+ maybe_append_or_save(
57
+ store=store,
58
+ handle=handle,
59
+ user_id=user_id,
60
+ work_session_id=work_session_id,
61
+ before=before,
62
+ )
63
+ return {
64
+ "anchor": {
65
+ "transition_id": transition_id,
66
+ "node_id": node.node_id,
67
+ "label": label,
68
+ }
69
+ }
70
+
71
+
72
+ def cli_anchor(args) -> int:
73
+ result = run_anchor_command(
74
+ run_id=resolve_run_id_from_args(args),
75
+ from_node_id=args.from_node_id,
76
+ label=args.label,
77
+ store_dir=args.store_dir,
78
+ user_id=resolve_user_id_from_args(args),
79
+ work_session_id=resolve_work_session_id_from_args(args),
80
+ )
81
+ print(json.dumps(result["anchor"], ensure_ascii=False, indent=2))
82
+ return 0