tcw-cli 0.20.1__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.
- tcw/__init__.py +1 -0
- tcw/capabilities/__init__.py +0 -0
- tcw/capabilities/cli.py +295 -0
- tcw/cli.py +187 -0
- tcw/refs.py +132 -0
- tcw/serve/__init__.py +1349 -0
- tcw/serve/dist/client/assets/index-3NGTfZHg.js +181 -0
- tcw/serve/dist/client/assets/index-BLi-VXwR.css +1 -0
- tcw/serve/dist/client/index.html +14 -0
- tcw/serve/dist/client/theme-init.js +20 -0
- tcw/serve/dist/server.cjs +35167 -0
- tcw/serve/runtime.py +192 -0
- tcw/store/__init__.py +0 -0
- tcw/store/base.py +1417 -0
- tcw/store/fs.py +3295 -0
- tcw/store/project.py +421 -0
- tcw/taxonomy/__init__.py +0 -0
- tcw/taxonomy/cli.py +234 -0
- tcw/validate.py +193 -0
- tcw/work/__init__.py +0 -0
- tcw/work/cli.py +1121 -0
- tcw/work/hooks.py +99 -0
- tcw/work/recursion.py +251 -0
- tcw_cli-0.20.1.dist-info/METADATA +1245 -0
- tcw_cli-0.20.1.dist-info/RECORD +29 -0
- tcw_cli-0.20.1.dist-info/WHEEL +5 -0
- tcw_cli-0.20.1.dist-info/entry_points.txt +2 -0
- tcw_cli-0.20.1.dist-info/licenses/LICENSE +201 -0
- tcw_cli-0.20.1.dist-info/top_level.txt +1 -0
tcw/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.20.1"
|
|
File without changes
|
tcw/capabilities/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""`tcw capabilities` — the user stories. Path-addressed folder capabilities."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from tcw.store.base import Capability, RefError
|
|
7
|
+
from tcw.store.base import AmbiguousRef
|
|
8
|
+
from tcw.store.fs import FsCapabilitiesStore, FsTaxonomyStore, find_node, git_root
|
|
9
|
+
|
|
10
|
+
NAME = "capabilities"
|
|
11
|
+
SUBCOMMANDS = {"init", "list", "show", "path", "add", "search", "check", "set", "reset", "extends", "drift"}
|
|
12
|
+
DEFAULT_SUBCOMMAND = "show" # `tcw capabilities <path>` == `tcw capabilities show <path>`
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _init(args: argparse.Namespace) -> int:
|
|
16
|
+
from tcw.cli import run_init # function-local: top-level cli imports this module
|
|
17
|
+
return run_init([NAME], args.id)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _store() -> FsCapabilitiesStore | None:
|
|
21
|
+
node = find_node(NAME)
|
|
22
|
+
if node is None:
|
|
23
|
+
print("tcw capabilities: no tcw capabilities node here — run `tcw init` in the project folder.",
|
|
24
|
+
file=sys.stderr)
|
|
25
|
+
return None
|
|
26
|
+
return FsCapabilitiesStore.open(node)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _taxonomy_for(node):
|
|
30
|
+
"""The node's taxonomy store, if it has one (for cross-component Subject check)."""
|
|
31
|
+
return FsTaxonomyStore.open(node) if (node / "docs" / "taxonomy").is_dir() else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _stdin_body() -> str:
|
|
35
|
+
if sys.stdin.isatty():
|
|
36
|
+
return ""
|
|
37
|
+
try:
|
|
38
|
+
return sys.stdin.read()
|
|
39
|
+
except (OSError, ValueError):
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fmt(v) -> str:
|
|
44
|
+
return ", ".join(v) if isinstance(v, list) else str(v)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _print_cap(cap: Capability) -> None:
|
|
48
|
+
tag = f" [{cap.origin}]" if cap.origin != "local" else ""
|
|
49
|
+
print(f"## {cap.name} ({cap.qualified}){tag}")
|
|
50
|
+
if cap.id:
|
|
51
|
+
print(f"**id:** {cap.id}")
|
|
52
|
+
for k, v in cap.fields.items():
|
|
53
|
+
print(f"**{k}:** {_fmt(v)}")
|
|
54
|
+
body = cap.body.strip()
|
|
55
|
+
if body:
|
|
56
|
+
print()
|
|
57
|
+
print(body)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _list(args: argparse.Namespace) -> int:
|
|
61
|
+
st = _store()
|
|
62
|
+
if st is None:
|
|
63
|
+
return 1
|
|
64
|
+
for c in st.list_all(status=args.status, namespace=args.namespace,
|
|
65
|
+
local_only=args.local_only):
|
|
66
|
+
print(f"[{c.status}]\t{c.qualified}\t{c.name}")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _show(args: argparse.Namespace) -> int:
|
|
71
|
+
st = _store()
|
|
72
|
+
if st is None:
|
|
73
|
+
return 1
|
|
74
|
+
try:
|
|
75
|
+
cap = st.get(args.id)
|
|
76
|
+
except (RefError, AmbiguousRef) as e:
|
|
77
|
+
print(f"tcw capabilities show: {e}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
if cap is None:
|
|
80
|
+
print(f"tcw capabilities show: no such capability: {args.id}", file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
_print_cap(cap)
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _path(args: argparse.Namespace) -> int:
|
|
87
|
+
st = _store()
|
|
88
|
+
if st is None:
|
|
89
|
+
return 1
|
|
90
|
+
print(st.root)
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _add(args: argparse.Namespace) -> int:
|
|
95
|
+
st = _store()
|
|
96
|
+
if st is None:
|
|
97
|
+
return 1
|
|
98
|
+
try:
|
|
99
|
+
cap = st.add(args.path, name=args.name, status=args.status, body=_stdin_body())
|
|
100
|
+
except (ValueError, RefError) as e:
|
|
101
|
+
print(f"tcw capabilities add: {e}", file=sys.stderr)
|
|
102
|
+
return 1
|
|
103
|
+
print(f"Added capability {cap.path} ({cap.id})")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _set(args: argparse.Namespace) -> int:
|
|
108
|
+
st = _store()
|
|
109
|
+
if st is None:
|
|
110
|
+
return 1
|
|
111
|
+
fields: dict = {}
|
|
112
|
+
if args.status:
|
|
113
|
+
fields["Status"] = args.status
|
|
114
|
+
for kv in (args.field or []):
|
|
115
|
+
if "=" not in kv:
|
|
116
|
+
print(f"tcw capabilities set: --field must be K=V: {kv}", file=sys.stderr)
|
|
117
|
+
return 1
|
|
118
|
+
k, v = kv.split("=", 1)
|
|
119
|
+
fields[k.strip()] = v.strip()
|
|
120
|
+
if not fields:
|
|
121
|
+
print("tcw capabilities set: need --status or at least one --field", file=sys.stderr)
|
|
122
|
+
return 1
|
|
123
|
+
try:
|
|
124
|
+
cap = st.set(args.id, fields)
|
|
125
|
+
except (ValueError, RefError) as e:
|
|
126
|
+
print(f"tcw capabilities set: {e}", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
print(f"Set {cap.path}")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _reset(args: argparse.Namespace) -> int:
|
|
133
|
+
st = _store()
|
|
134
|
+
if st is None:
|
|
135
|
+
return 1
|
|
136
|
+
try:
|
|
137
|
+
st.reset(args.id)
|
|
138
|
+
except (ValueError, RefError) as e:
|
|
139
|
+
print(f"tcw capabilities reset: {e}", file=sys.stderr)
|
|
140
|
+
return 1
|
|
141
|
+
print(f"reset {args.id}")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _search(args: argparse.Namespace) -> int:
|
|
146
|
+
st = _store()
|
|
147
|
+
if st is None:
|
|
148
|
+
return 1
|
|
149
|
+
for c in st.search(args.query):
|
|
150
|
+
print(f"{c.qualified}\t{c.name}")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _extends(args: argparse.Namespace) -> int:
|
|
155
|
+
st = _store()
|
|
156
|
+
if st is None:
|
|
157
|
+
return 1
|
|
158
|
+
try:
|
|
159
|
+
if args.rm:
|
|
160
|
+
st.extends_remove(args.project_id)
|
|
161
|
+
print(f"Removed extends project {args.project_id}")
|
|
162
|
+
else:
|
|
163
|
+
st.extends_add(args.project_id)
|
|
164
|
+
print(f"Added extends project {args.project_id}")
|
|
165
|
+
except (ValueError, RefError) as e:
|
|
166
|
+
print(f"tcw capabilities extends: {e}", file=sys.stderr)
|
|
167
|
+
return 1
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _drift(args: argparse.Namespace) -> int:
|
|
172
|
+
"""Report capabilities that drifted from ground truth: inherited-but-unreviewed
|
|
173
|
+
(status is the master default, never locally ruled on) and local-Missing whose
|
|
174
|
+
Planning doc points to a completed work item (declared, shipped, never flipped).
|
|
175
|
+
Read-only; exits non-zero when any drift is found."""
|
|
176
|
+
node = find_node(NAME)
|
|
177
|
+
if node is None:
|
|
178
|
+
print("tcw capabilities: no tcw capabilities node here — run `tcw init` in the project folder.",
|
|
179
|
+
file=sys.stderr)
|
|
180
|
+
return 1
|
|
181
|
+
st = FsCapabilitiesStore.open(node)
|
|
182
|
+
|
|
183
|
+
unreviewed = st.unreviewed_inherited()
|
|
184
|
+
shipped_missing = _shipped_but_missing(node, st)
|
|
185
|
+
|
|
186
|
+
for c in unreviewed:
|
|
187
|
+
print(f"unreviewed\t{c.qualified}\t(inherited; status is the master default)")
|
|
188
|
+
for path, slug in shipped_missing:
|
|
189
|
+
print(f"shipped-missing\t{path}\t(Planning doc {slug} is completed, still Missing)")
|
|
190
|
+
|
|
191
|
+
n = len(unreviewed) + len(shipped_missing)
|
|
192
|
+
if n:
|
|
193
|
+
print(f"{n} capability(ies) drifted.", file=sys.stderr)
|
|
194
|
+
return 1
|
|
195
|
+
print("no capability drift")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _shipped_but_missing(node, st) -> list[tuple[str, str]]:
|
|
200
|
+
"""Local Missing capabilities whose `Planning doc` names a completed work item.
|
|
201
|
+
Read-only follow of an existing capability→work forward pointer; degrades to
|
|
202
|
+
empty when no work node is present (no hard cross-axis dependency)."""
|
|
203
|
+
if not (node / "docs" / "work").is_dir():
|
|
204
|
+
return []
|
|
205
|
+
from tcw.store.fs import FsWorkStore
|
|
206
|
+
work = FsWorkStore.open(node)
|
|
207
|
+
out: list[tuple[str, str]] = []
|
|
208
|
+
for c in st.list_all(local_only=True):
|
|
209
|
+
if c.status != "Missing":
|
|
210
|
+
continue
|
|
211
|
+
slug = c.fields.get("Planning doc")
|
|
212
|
+
if not slug:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
item = work.get(str(slug))
|
|
216
|
+
except Exception:
|
|
217
|
+
item = None
|
|
218
|
+
# `completed` alone, deliberately NOT `RESOLVED_STATUSES`: this asks
|
|
219
|
+
# "did it ship?", not "is it closed?". A discarded item's capability is
|
|
220
|
+
# *supposed* to stay Missing (or be marked Omitted) — reporting it as
|
|
221
|
+
# shipped-but-unreconciled would be a false positive, which is exactly
|
|
222
|
+
# what happened before `discarded` existed.
|
|
223
|
+
if item is not None and item.status == "completed":
|
|
224
|
+
out.append((c.path, str(slug)))
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _check(args: argparse.Namespace) -> int:
|
|
229
|
+
node = find_node(NAME)
|
|
230
|
+
if node is None:
|
|
231
|
+
print("tcw capabilities: no tcw capabilities node here — run `tcw init` in the project folder.",
|
|
232
|
+
file=sys.stderr)
|
|
233
|
+
return 1
|
|
234
|
+
problems = FsCapabilitiesStore.open(node).check(taxonomy=_taxonomy_for(node))
|
|
235
|
+
for p in problems:
|
|
236
|
+
print(p, file=sys.stderr)
|
|
237
|
+
if problems:
|
|
238
|
+
print(f"{len(problems)} problem(s).", file=sys.stderr)
|
|
239
|
+
return 1
|
|
240
|
+
print("capabilities OK")
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def add_subparser(sub: argparse._SubParsersAction) -> None:
|
|
245
|
+
p = sub.add_parser(NAME, help="the user stories — what a user can do")
|
|
246
|
+
g = p.add_subparsers(dest="cmd", required=True)
|
|
247
|
+
|
|
248
|
+
pi = g.add_parser("init", help="scaffold docs/capabilities/ (mirror of `tcw init capabilities`)")
|
|
249
|
+
pi.add_argument("--id", help="canonical project ID (required for new/legacy nodes)")
|
|
250
|
+
pi.set_defaults(func=_init)
|
|
251
|
+
|
|
252
|
+
pl = g.add_parser("list", help="list capabilities, flagged by status + origin")
|
|
253
|
+
pl.add_argument("--status")
|
|
254
|
+
pl.add_argument("--namespace")
|
|
255
|
+
pl.add_argument("--local-only", action="store_true",
|
|
256
|
+
help="exclude inherited (federated) capabilities")
|
|
257
|
+
pl.set_defaults(func=_list)
|
|
258
|
+
|
|
259
|
+
ps = g.add_parser("show", help="read a capability by path")
|
|
260
|
+
ps.add_argument("id", metavar="path")
|
|
261
|
+
ps.set_defaults(func=_show)
|
|
262
|
+
|
|
263
|
+
g.add_parser("path", help="print the capabilities store folder path").set_defaults(func=_path)
|
|
264
|
+
|
|
265
|
+
pa = g.add_parser("add", help="scaffold a capability folder")
|
|
266
|
+
pa.add_argument("path", metavar="namespace/path")
|
|
267
|
+
pa.add_argument("name", nargs="?")
|
|
268
|
+
pa.add_argument("-s", "--status", default="Missing")
|
|
269
|
+
pa.set_defaults(func=_add)
|
|
270
|
+
|
|
271
|
+
pset = g.add_parser("set", help="update a capability's status/fields in place")
|
|
272
|
+
pset.add_argument("id", metavar="path")
|
|
273
|
+
pset.add_argument("--status", help="shorthand for --field Status=<S>")
|
|
274
|
+
pset.add_argument("--field", action="append", metavar="K=V",
|
|
275
|
+
help="set a metadata field (repeatable; Subject accepts a,b,c)")
|
|
276
|
+
pset.set_defaults(func=_set)
|
|
277
|
+
|
|
278
|
+
prst = g.add_parser("reset", help="drop a local override, re-inheriting upstream")
|
|
279
|
+
prst.add_argument("id", metavar="path")
|
|
280
|
+
prst.set_defaults(func=_reset)
|
|
281
|
+
|
|
282
|
+
pse = g.add_parser("search", help="search names + bodies")
|
|
283
|
+
pse.add_argument("query")
|
|
284
|
+
pse.set_defaults(func=_search)
|
|
285
|
+
|
|
286
|
+
pe = g.add_parser("extends", help="federate another project's capabilities")
|
|
287
|
+
pe.add_argument("project_id")
|
|
288
|
+
pe.add_argument("--rm", action="store_true", help="remove the project instead")
|
|
289
|
+
pe.set_defaults(func=_extends)
|
|
290
|
+
|
|
291
|
+
pc = g.add_parser("check", help="validate paths, subject/feature refs, federation, metadata")
|
|
292
|
+
pc.set_defaults(func=_check)
|
|
293
|
+
|
|
294
|
+
pd = g.add_parser("drift", help="report unreviewed inherited + shipped-but-Missing capabilities")
|
|
295
|
+
pd.set_defaults(func=_drift)
|
tcw/cli.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Top-level `tcw` CLI: dispatches `init | taxonomy | capabilities | work`.
|
|
2
|
+
|
|
3
|
+
Built component groups register their own subparsers; the rest stub to "not yet
|
|
4
|
+
implemented" until their phase lands (capabilities = Phase 3, work = Phase 5).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from tcw import __version__
|
|
12
|
+
from tcw.capabilities import cli as capabilities_cli
|
|
13
|
+
from tcw.serve import DEFAULT_PORT, serve
|
|
14
|
+
from tcw.store.fs import COMPONENTS, SENTINEL, find_node_root, git_root, init
|
|
15
|
+
from tcw.store.project import FsProjectRegistry
|
|
16
|
+
import yaml
|
|
17
|
+
from tcw.taxonomy import cli as taxonomy_cli
|
|
18
|
+
from tcw.work import cli as work_cli
|
|
19
|
+
|
|
20
|
+
# Component CLI modules (each exposes NAME / SUBCOMMANDS / DEFAULT_SUBCOMMAND /
|
|
21
|
+
# add_subparser). All three components are now built.
|
|
22
|
+
_BUILT = [taxonomy_cli, capabilities_cli, work_cli]
|
|
23
|
+
_STUBBED = [c for c in COMPONENTS if c not in {m.NAME for m in _BUILT}]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_init(components: list[str], project_id: str | None = None,
|
|
27
|
+
work_path: str | None = None) -> int:
|
|
28
|
+
"""Scaffold `docs/<component>/` trees under the current directory, mark it a
|
|
29
|
+
node, and report. Shared by `tcw init` and each `tcw <component> init`."""
|
|
30
|
+
root = Path.cwd()
|
|
31
|
+
if git_root(root) is None: # returns the repo root for any dir inside it
|
|
32
|
+
print("tcw init: not inside a git repository. Run `git init` first.", file=sys.stderr)
|
|
33
|
+
return 1
|
|
34
|
+
unknown = [c for c in components if c not in COMPONENTS]
|
|
35
|
+
if unknown:
|
|
36
|
+
print(f"tcw init: unknown component(s): {', '.join(unknown)}. "
|
|
37
|
+
f"Choose from: {', '.join(COMPONENTS)}.", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
sentinel = root / SENTINEL
|
|
40
|
+
if project_id is None:
|
|
41
|
+
try:
|
|
42
|
+
configured = yaml.safe_load(sentinel.read_text(encoding="utf-8")) if sentinel.exists() else {}
|
|
43
|
+
except yaml.YAMLError as error:
|
|
44
|
+
print(f"tcw init: invalid {SENTINEL}: {error}", file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
if not isinstance(configured, dict) or not configured.get("id"):
|
|
47
|
+
print(
|
|
48
|
+
"tcw init: new or legacy TCW nodes require `--id <project-id>`; "
|
|
49
|
+
"IDs are not inferred",
|
|
50
|
+
file=sys.stderr,
|
|
51
|
+
)
|
|
52
|
+
return 1
|
|
53
|
+
try:
|
|
54
|
+
created = init(components, root, project_id,
|
|
55
|
+
Path(work_path).expanduser() if work_path is not None else None)
|
|
56
|
+
except (ValueError, OSError) as error:
|
|
57
|
+
print(f"tcw init: {error}", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
print(f"Scaffolded {len(created)} dir(s):")
|
|
60
|
+
for p in created:
|
|
61
|
+
try:
|
|
62
|
+
shown = p.relative_to(root)
|
|
63
|
+
except ValueError:
|
|
64
|
+
shown = p
|
|
65
|
+
print(f" {shown}")
|
|
66
|
+
print(f"Node marker: {SENTINEL}") # deterministic across runs
|
|
67
|
+
if "work" in components:
|
|
68
|
+
print(".gitignore: resolved work (completed/, discarded/) stays on disk, "
|
|
69
|
+
"out of the tracked tree")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
74
|
+
return run_init(args.components or list(COMPONENTS), args.id, args.work_path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _not_yet(name: str):
|
|
78
|
+
def run(args: argparse.Namespace) -> int:
|
|
79
|
+
print(f"tcw {name}: not yet implemented.", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
return run
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
85
|
+
node_root = find_node_root()
|
|
86
|
+
if node_root is None:
|
|
87
|
+
print("tcw validate: no tcw node here — run `tcw init` in the project folder.",
|
|
88
|
+
file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
registry = FsProjectRegistry.open(node_root)
|
|
91
|
+
registry_problems = registry.check()
|
|
92
|
+
if registry_problems:
|
|
93
|
+
for problem in registry_problems:
|
|
94
|
+
print(problem, file=sys.stderr)
|
|
95
|
+
print(f"{len(registry_problems)} project graph problem(s).", file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
from tcw.validate import validate
|
|
98
|
+
recurse = args.path is None and not args.no_recurse
|
|
99
|
+
projects = [registry.current, *registry.descendants()] if recurse else [registry.current]
|
|
100
|
+
problems: list[str] = []
|
|
101
|
+
for project in projects:
|
|
102
|
+
project_problems = validate(Path(project.locator), args.path)
|
|
103
|
+
if len(projects) > 1:
|
|
104
|
+
project_problems = [f"[{project.id}] {problem}" for problem in project_problems]
|
|
105
|
+
problems.extend(project_problems)
|
|
106
|
+
for p in problems:
|
|
107
|
+
print(p, file=sys.stderr)
|
|
108
|
+
if problems:
|
|
109
|
+
print(f"{len(problems)} problem(s).", file=sys.stderr)
|
|
110
|
+
return 1
|
|
111
|
+
print("validate OK")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
116
|
+
# Descendant node boards are aggregated by default (like
|
|
117
|
+
# `tcw work list --include-descendants`).
|
|
118
|
+
node_root = find_node_root()
|
|
119
|
+
if node_root is None:
|
|
120
|
+
print("tcw serve: no tcw node here — run `tcw init --id <project-id>`.",
|
|
121
|
+
file=sys.stderr)
|
|
122
|
+
return 1
|
|
123
|
+
FsProjectRegistry.open(node_root).require_valid()
|
|
124
|
+
return serve(port=args.port, open_browser=not args.no_open,
|
|
125
|
+
include_descendants=True)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
129
|
+
parser = argparse.ArgumentParser(prog="tcw", description="Taxonomy · Capabilities · Work.")
|
|
130
|
+
parser.add_argument("--version", action="version", version=f"tcw {__version__}")
|
|
131
|
+
sub = parser.add_subparsers(dest="group", required=True)
|
|
132
|
+
|
|
133
|
+
p_init = sub.add_parser("init", help="scaffold component doc trees in this git repo")
|
|
134
|
+
p_init.add_argument("components", nargs="*",
|
|
135
|
+
help=f"any of: {', '.join(COMPONENTS)} (default: all)")
|
|
136
|
+
p_init.add_argument("--id", help="canonical project ID (required for new/legacy nodes)")
|
|
137
|
+
p_init.add_argument("--work-path", help="filesystem location for the work store")
|
|
138
|
+
p_init.set_defaults(func=_cmd_init)
|
|
139
|
+
|
|
140
|
+
p_validate = sub.add_parser(
|
|
141
|
+
"validate", help="check YAML soundness, tcw:// links, and component integrity")
|
|
142
|
+
p_validate.add_argument("path", nargs="?",
|
|
143
|
+
help="narrow the active project scan to one file or directory (disables recursion)")
|
|
144
|
+
p_validate.add_argument(
|
|
145
|
+
"--no-recurse", action="store_true",
|
|
146
|
+
help="validate only the active project, excluding registered descendants",
|
|
147
|
+
)
|
|
148
|
+
p_validate.set_defaults(func=_cmd_validate)
|
|
149
|
+
|
|
150
|
+
p_serve = sub.add_parser("serve", help="serve a local read-only web viewer")
|
|
151
|
+
p_serve.add_argument("--port", type=int, default=DEFAULT_PORT,
|
|
152
|
+
help=f"loopback port to bind (default: {DEFAULT_PORT})")
|
|
153
|
+
p_serve.add_argument("--no-open", action="store_true",
|
|
154
|
+
help="do not open a browser automatically")
|
|
155
|
+
p_serve.set_defaults(func=_cmd_serve)
|
|
156
|
+
|
|
157
|
+
for mod in _BUILT:
|
|
158
|
+
mod.add_subparser(sub)
|
|
159
|
+
for name in _STUBBED:
|
|
160
|
+
p = sub.add_parser(name, help=f"{name} commands (not yet implemented)")
|
|
161
|
+
p.set_defaults(func=_not_yet(name))
|
|
162
|
+
|
|
163
|
+
return parser
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _normalize(argv: list[str]) -> list[str]:
|
|
167
|
+
"""Sugar: `tcw <component> <path>` → `tcw <component> show <path>`."""
|
|
168
|
+
if len(argv) >= 2 and not argv[1].startswith("-"):
|
|
169
|
+
for mod in _BUILT:
|
|
170
|
+
default = getattr(mod, "DEFAULT_SUBCOMMAND", None)
|
|
171
|
+
if default and argv[0] == mod.NAME and argv[1] not in mod.SUBCOMMANDS:
|
|
172
|
+
return [argv[0], default, *argv[1:]]
|
|
173
|
+
return argv
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main(argv: list[str] | None = None) -> int:
|
|
177
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
178
|
+
args = build_parser().parse_args(_normalize(argv))
|
|
179
|
+
try:
|
|
180
|
+
return args.func(args)
|
|
181
|
+
except ValueError as error:
|
|
182
|
+
print(f"tcw: {error}", file=sys.stderr)
|
|
183
|
+
return 1
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
sys.exit(main())
|
tcw/refs.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The `tcw://` reference protocol — a portable way to point at a TCW object.
|
|
2
|
+
|
|
3
|
+
Grammar: ``tcw://[<namespace>/]<axis>/<ref>``
|
|
4
|
+
- ``<axis>`` is ``T`` (Taxonomy), ``C`` (Capabilities), or ``W`` (Work).
|
|
5
|
+
- ``<namespace>`` (optional) locates the object in another project: an
|
|
6
|
+
``extends`` alias for T/C, a descendant node path for W. Absent = local.
|
|
7
|
+
- ``<ref>`` is the identifier within that axis.
|
|
8
|
+
|
|
9
|
+
`parse_tcw_uri` is a pure, total function (never raises) — the abstract grammar.
|
|
10
|
+
`resolve_tcw_ref` is thin CLI/serve adapter glue: it imports the FS stores and
|
|
11
|
+
dispatches through their existing ``get()`` / ``resolve_qualified_work_ref`` (no
|
|
12
|
+
new store-interface method — litmus-clean), and never propagates a store
|
|
13
|
+
exception to a caller scanning many links.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from urllib.parse import unquote
|
|
21
|
+
|
|
22
|
+
from tcw.store.fs import (
|
|
23
|
+
FsCapabilitiesStore,
|
|
24
|
+
FsTaxonomyStore,
|
|
25
|
+
qualified_work_ref_problem,
|
|
26
|
+
resolve_qualified_work_ref,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_SCHEME = "tcw://"
|
|
30
|
+
_AXES = {"T", "C", "W"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class TcwRef:
|
|
35
|
+
namespace: str # "" = local
|
|
36
|
+
axis: str # normalized upper: "T" | "C" | "W"
|
|
37
|
+
ref: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ResolveResult:
|
|
42
|
+
ok: bool
|
|
43
|
+
axis: str | None
|
|
44
|
+
key: str | None
|
|
45
|
+
reason: str
|
|
46
|
+
project: str = "" # owning project id for a foreign work ref; "" = local
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _segment_ok(seg: str) -> bool:
|
|
50
|
+
"""A decoded path segment is safe iff it is not a traversal token and holds
|
|
51
|
+
no control/NUL/backslash chars (mirrors the guard style of `_safe_store_id`)."""
|
|
52
|
+
if seg in (".", ".."):
|
|
53
|
+
return False
|
|
54
|
+
return not any(ord(c) < 0x20 or c == "\x7f" or c == "\\" for c in seg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_tcw_uri(uri: str) -> TcwRef | None:
|
|
58
|
+
"""Parse a ``tcw://`` uri into its (namespace, axis, ref). Total: returns
|
|
59
|
+
None on any malformed input, never raises.
|
|
60
|
+
|
|
61
|
+
Split the remainder on ``/`` FIRST, then percent-decode each segment (so a
|
|
62
|
+
``%2F`` inside a segment can't inject a spurious separator/axis — matches
|
|
63
|
+
the React client's ``parsePath``). The axis is the first segment whose ``.upper()``
|
|
64
|
+
is one of T/C/W; the first-bare-axis-wins collision (``tcw://T/C/ref``) is a
|
|
65
|
+
documented limitation. Empty segments (multiple slashes) are dropped.
|
|
66
|
+
"""
|
|
67
|
+
if not isinstance(uri, str) or not uri.startswith(_SCHEME):
|
|
68
|
+
return None
|
|
69
|
+
raw_segs = [s for s in uri[len(_SCHEME):].split("/") if s] # drop empties
|
|
70
|
+
segs = [unquote(s) for s in raw_segs]
|
|
71
|
+
if not all(_segment_ok(s) for s in segs):
|
|
72
|
+
return None
|
|
73
|
+
axis_idx = next((i for i, s in enumerate(segs) if s.upper() in _AXES), -1)
|
|
74
|
+
if axis_idx == -1:
|
|
75
|
+
return None
|
|
76
|
+
ref = "/".join(segs[axis_idx + 1:])
|
|
77
|
+
if not ref:
|
|
78
|
+
return None
|
|
79
|
+
return TcwRef("/".join(segs[:axis_idx]), segs[axis_idx].upper(), ref)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_tcw_ref(node_root: Path | None, uri: str) -> ResolveResult:
|
|
83
|
+
"""Resolve a ``tcw://`` uri against the node at ``node_root``, returning the
|
|
84
|
+
SPA object key (namespace-qualified where present). Never propagates a store
|
|
85
|
+
exception — a store failure becomes ``ok=False`` with a reason.
|
|
86
|
+
|
|
87
|
+
``ok`` answers one question only: **does this reference resolve in the
|
|
88
|
+
registered graph?** That is what `tcw validate` asks. Whether a *viewer* can
|
|
89
|
+
open it is a different question with a different answer per server, so it is
|
|
90
|
+
not decided here — a foreign work ref reports its owning project in
|
|
91
|
+
``project`` and the viewer gates on that (see `tcw serve`'s ``/api/resolve``).
|
|
92
|
+
Conflating the two is what made a valid cross-node link report ``ok`` and then
|
|
93
|
+
dead-end in the SPA: the two spellings of a namespaced work ref took different
|
|
94
|
+
paths, and the one people actually write never reached the gate at all.
|
|
95
|
+
|
|
96
|
+
Both spellings now produce the same qualified key. ``parse_tcw_uri`` treats the
|
|
97
|
+
first T/C/W segment as the axis, so ``tcw://<id>/W/<slug>`` arrives with a
|
|
98
|
+
parsed namespace while ``tcw://W/<id>/<slug>`` arrives as a bare ref whose
|
|
99
|
+
qualifier is resolved by ``resolve_qualified_work_ref``; keying off where the
|
|
100
|
+
ref actually landed, rather than off the spelling, covers both.
|
|
101
|
+
"""
|
|
102
|
+
parsed = parse_tcw_uri(uri)
|
|
103
|
+
if parsed is None:
|
|
104
|
+
return ResolveResult(False, None, None, "malformed tcw:// uri")
|
|
105
|
+
if node_root is None:
|
|
106
|
+
return ResolveResult(False, parsed.axis, None, "no tcw node")
|
|
107
|
+
ns_ref = f"{parsed.namespace}/{parsed.ref}" if parsed.namespace else parsed.ref
|
|
108
|
+
try:
|
|
109
|
+
if parsed.axis == "T":
|
|
110
|
+
term = FsTaxonomyStore.open(node_root).get(ns_ref)
|
|
111
|
+
if term is None:
|
|
112
|
+
return ResolveResult(False, "T", None, f"no taxonomy term: {ns_ref}")
|
|
113
|
+
return ResolveResult(True, "T", term.qualified, "")
|
|
114
|
+
if parsed.axis == "C":
|
|
115
|
+
cap = FsCapabilitiesStore.open(node_root).get(ns_ref)
|
|
116
|
+
if cap is None:
|
|
117
|
+
return ResolveResult(False, "C", None, f"no capability: {ns_ref}")
|
|
118
|
+
return ResolveResult(True, "C", cap.qualified, "")
|
|
119
|
+
# axis == "W"
|
|
120
|
+
resolved = resolve_qualified_work_ref(node_root, ns_ref)
|
|
121
|
+
if resolved is None:
|
|
122
|
+
return ResolveResult(
|
|
123
|
+
False, "W", None, qualified_work_ref_problem(node_root, ns_ref))
|
|
124
|
+
store, bare = resolved
|
|
125
|
+
if store.node_root == node_root.resolve(): # landed locally
|
|
126
|
+
return ResolveResult(True, "W", bare, "")
|
|
127
|
+
# Foreign: the qualifier is a project id (a status-path locator is always
|
|
128
|
+
# local), and the SPA keys foreign items the way the board prints them.
|
|
129
|
+
project = ns_ref.partition("/")[0]
|
|
130
|
+
return ResolveResult(True, "W", f"{project}/{bare}", "", project)
|
|
131
|
+
except Exception as e: # store errors (AmbiguousRef, MultipleMatch, IO) -> ok=False
|
|
132
|
+
return ResolveResult(False, parsed.axis, None, str(e))
|