taktcli 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {taktcli-0.2.0 → taktcli-0.3.0}/PKG-INFO +2 -2
  2. {taktcli-0.2.0 → taktcli-0.3.0}/README.md +1 -1
  3. {taktcli-0.2.0 → taktcli-0.3.0}/pyproject.toml +1 -1
  4. taktcli-0.3.0/taktcli/__init__.py +27 -0
  5. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/_run.py +14 -2
  6. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/task.py +42 -3
  7. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/wiki.py +166 -6
  8. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/PKG-INFO +2 -2
  9. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/SOURCES.txt +1 -0
  10. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_task.py +74 -0
  11. taktcli-0.3.0/tests/test_version.py +44 -0
  12. taktcli-0.3.0/tests/test_wiki.py +459 -0
  13. taktcli-0.2.0/taktcli/__init__.py +0 -6
  14. taktcli-0.2.0/tests/test_wiki.py +0 -219
  15. {taktcli-0.2.0 → taktcli-0.3.0}/setup.cfg +0 -0
  16. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/__main__.py +0 -0
  17. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/auth.py +0 -0
  18. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/__init__.py +0 -0
  19. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/_exec.py +0 -0
  20. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/auth.py +0 -0
  21. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/file.py +0 -0
  22. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/gql.py +0 -0
  23. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/hotpath.py +0 -0
  24. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/label.py +0 -0
  25. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/notif.py +0 -0
  26. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/project.py +0 -0
  27. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/schema.py +0 -0
  28. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/watch.py +0 -0
  29. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/workspace.py +0 -0
  30. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/errors.py +0 -0
  31. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/introspection.py +0 -0
  32. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/io.py +0 -0
  33. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/transport.py +0 -0
  34. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/watch.py +0 -0
  35. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/dependency_links.txt +0 -0
  36. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/entry_points.txt +0 -0
  37. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/requires.txt +0 -0
  38. {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/top_level.txt +0 -0
  39. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_auth.py +0 -0
  40. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_auth_commands.py +0 -0
  41. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_cli.py +0 -0
  42. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_file.py +0 -0
  43. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_gql.py +0 -0
  44. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_hotpath.py +0 -0
  45. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_introspection.py +0 -0
  46. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_io.py +0 -0
  47. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_label.py +0 -0
  48. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_notif.py +0 -0
  49. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_project.py +0 -0
  50. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_schema.py +0 -0
  51. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_transport.py +0 -0
  52. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_watch.py +0 -0
  53. {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taktcli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core
5
5
  Author: Takt
6
6
  License: MIT
@@ -139,7 +139,7 @@ Every MCP tool maps to a verb:
139
139
  | `get_task_comments` | `task comments <id>` |
140
140
  | `attach_result` | `task result <id>` |
141
141
  | `get_task_results` | `task results <id>` |
142
- | `delete_result` | `task result delete <id>` |
142
+ | `delete_result` | `task result-delete <id>` |
143
143
  | `wiki_read` | `wiki read <path>` |
144
144
  | `wiki_search` / `wiki_semantic_search` | `wiki search <q>` (`--semantic`) |
145
145
  | `wiki_grep` | `wiki grep <pattern>` |
@@ -123,7 +123,7 @@ Every MCP tool maps to a verb:
123
123
  | `get_task_comments` | `task comments <id>` |
124
124
  | `attach_result` | `task result <id>` |
125
125
  | `get_task_results` | `task results <id>` |
126
- | `delete_result` | `task result delete <id>` |
126
+ | `delete_result` | `task result-delete <id>` |
127
127
  | `wiki_read` | `wiki read <path>` |
128
128
  | `wiki_search` / `wiki_semantic_search` | `wiki search <q>` (`--semantic`) |
129
129
  | `wiki_grep` | `wiki grep <pattern>` |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taktcli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,27 @@
1
+ """taktcli — authorized GraphQL CLI for Takt.
2
+
3
+ Stdlib-only core: argv -> GraphQL-over-HTTP. See PRD 002 in the takt wiki.
4
+ """
5
+
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ FALLBACK_VERSION = "0.0.0.dev0"
9
+
10
+
11
+ def _resolve_version() -> str:
12
+ """Resolve the version from the installed distribution's metadata.
13
+
14
+ `cli/pyproject.toml` is the single source of truth: its version is baked
15
+ into the package metadata at build time, so `--version` always reports the
16
+ version actually installed and can never drift from the released package.
17
+
18
+ Running from a source checkout that was never pip-installed leaves no
19
+ metadata to read; that case reports FALLBACK_VERSION rather than failing.
20
+ """
21
+ try:
22
+ return version("taktcli")
23
+ except PackageNotFoundError:
24
+ return FALLBACK_VERSION
25
+
26
+
27
+ __version__ = _resolve_version()
@@ -14,12 +14,24 @@ from ..io import format_output, read_path
14
14
  from ..transport import execute
15
15
 
16
16
 
17
- def run_op(args, document: str, variables: dict) -> int:
18
- """Execute a GraphQL document and print the formatted result."""
17
+ def run_op(args, document: str, variables: dict, *, hint=None) -> int:
18
+ """Execute a GraphQL document and print the formatted result.
19
+
20
+ ``hint`` is an optional ``data -> str`` callback for advisory guardrails: it
21
+ is called with the unwrapped ``.data`` payload once stdout has been written
22
+ and flushed, and a non-empty return is emitted on stderr (falsy = stay
23
+ quiet). Running it after the flush is what preserves the §4.2 contract that
24
+ stdout stays parseable JSON — a hint never interleaves with the payload,
25
+ and never changes the exit code.
26
+ """
19
27
  url, token = resolve_auth()
20
28
  envelope = execute(document, variables, url=url, token=token)
21
29
  print(format_output(envelope, raw=getattr(args, "raw", False), compact=getattr(args, "compact", False)))
22
30
  sys.stdout.flush()
31
+ if hint is not None:
32
+ message = hint((envelope or {}).get("data") or {})
33
+ if message:
34
+ print(message, file=sys.stderr)
23
35
  return 0
24
36
 
25
37
 
@@ -11,6 +11,7 @@ from ..io import parse_scalar
11
11
  from ._exec import (
12
12
  add_output_args,
13
13
  build_operation,
14
+ fetch,
14
15
  resolve_lease,
15
16
  run_operation,
16
17
  text_from_flags,
@@ -111,8 +112,43 @@ def search_run(args) -> int:
111
112
  return run_operation(args, document, variables)
112
113
 
113
114
 
115
+ def reject_empty(value: str, flag: str) -> str:
116
+ """Reject a blank flag value instead of letting it silently drop.
117
+
118
+ ``createTask`` coerces a falsy ``parentId`` to ``None`` server-side, so an
119
+ empty string would quietly create a *parentless* task at exit 0 — the
120
+ empty-string-drops-args class taktcli exists to avoid. Both parent flags go
121
+ through here so neither can fail silently.
122
+ """
123
+ if not value.strip():
124
+ raise ConfigError(f"{flag}: empty value — omit the flag to create a task with no parent")
125
+ return value
126
+
127
+
128
+ def resolve_parent_id(identifier: str) -> str:
129
+ """Resolve a task identifier to its internal UUID for ``CreateTaskInput.parentId``.
130
+
131
+ ``CreateTaskInput`` only accepts the parent's UUID, so ``--parent`` costs one
132
+ extra round-trip. An identifier that matches no task raises ``ConfigError``
133
+ (exit 3) rather than letting a ``null`` lookup silently create a parentless
134
+ task.
135
+ """
136
+ document, variables = build_operation(
137
+ "ResolveParent", "task",
138
+ [("id", "String!", "identifier", identifier)],
139
+ "{ id }",
140
+ )
141
+ envelope = fetch(document, variables)
142
+ parent = ((envelope.get("data") or {}).get("task") or {}).get("id")
143
+ if not parent:
144
+ raise ConfigError(f"--parent: no task found with identifier '{identifier}'")
145
+ return parent
146
+
147
+
114
148
  def create_run(args) -> int:
115
149
  description = text_from_flags(args.desc, args.desc_file, "--desc", "--desc-file")
150
+ if args.parent is not None and args.parent_id is not None:
151
+ raise ConfigError("pass either --parent or --parent-id, not both")
116
152
  payload = {"title": args.title}
117
153
  if description is not None:
118
154
  payload["description"] = description
@@ -122,8 +158,10 @@ def create_run(args) -> int:
122
158
  payload["labels"] = args.label
123
159
  if args.priority is not None:
124
160
  payload["priority"] = args.priority
125
- if args.parent_id:
126
- payload["parentId"] = args.parent_id
161
+ if args.parent is not None:
162
+ payload["parentId"] = resolve_parent_id(reject_empty(args.parent, "--parent"))
163
+ elif args.parent_id is not None:
164
+ payload["parentId"] = reject_empty(args.parent_id, "--parent-id")
127
165
  if args.assignee:
128
166
  payload["assignee"] = args.assignee
129
167
  if args.status:
@@ -343,7 +381,8 @@ def register(subparsers) -> None:
343
381
  p.add_argument("--depends-on", dest="depends_on", action="append", help="blocking dependency identifier (repeatable)")
344
382
  p.add_argument("--label", action="append", help="label name (repeatable)")
345
383
  p.add_argument("--priority", type=int)
346
- p.add_argument("--parent-id", dest="parent_id", help="parent task UUID")
384
+ p.add_argument("--parent", help="parent task identifier (e.g. takt-10)")
385
+ p.add_argument("--parent-id", dest="parent_id", help="parent task UUID (back-compat; prefer --parent)")
347
386
  p.add_argument("--assignee")
348
387
  p.add_argument("--status", choices=TASK_STATUSES)
349
388
  add_output_args(p)
@@ -11,6 +11,8 @@ Large markdown flows through ``--content-file`` / ``--old-file`` / ``--new-file`
11
11
  """
12
12
 
13
13
  import os
14
+ import re
15
+ import warnings
14
16
 
15
17
  from ..errors import ConfigError
16
18
  from ..io import add_output_args
@@ -18,6 +20,37 @@ from ._run import resolve_text, run_op
18
20
 
19
21
  STATUS_CHOICES = ("DRAFT", "PUBLISHED", "ARCHIVED")
20
22
 
23
+ ORDER_FIELD_CHOICES = ("RELEVANCE", "UPDATED_AT", "CREATED_AT", "PATH", "TITLE")
24
+ """``WikiPageOrderField`` minus ``MATCH_COUNT``, which needs a grep predicate.
25
+
26
+ ``wiki search`` maps to ``wikiPages`` without one, so ``MATCH_COUNT`` could only ever come
27
+ back as a server ``VALIDATION_ERROR`` — offering it in ``--help`` would advertise an
28
+ ordering this verb cannot produce. ``RELEVANCE`` stays: the positional query supplies the
29
+ ts_rank signal, and the one case that loses it (an empty query) is the server's call, not
30
+ a shape the CLI can rule out up front.
31
+ """
32
+
33
+ DIRECTION_CHOICES = ("ASC", "DESC")
34
+
35
+ SEMANTIC_ORDER_CONFLICT = (
36
+ "--order-by/--direction/--offset are not supported with --semantic: semantic rows are a "
37
+ "top-K selection over a similarity threshold, ranked by cosine score. Raise --limit and "
38
+ "slice locally, or drop --semantic to order and page the full-text surface."
39
+ )
40
+
41
+ TASK_IDENTIFIER_RE = re.compile(r"[a-z][a-z0-9-]*-\d+")
42
+ """Mirrors ``TASK_IDENTIFIER_PATTERN`` (backend ``mention_service``), which the
43
+ CLI cannot import — its core is stdlib-only and ships to PyPI on its own. A
44
+ task_prefix is validated ``^[a-z][a-z0-9-]*$`` at the project layer, so every
45
+ mintable identifier matches; matched with ``fullmatch`` so a stray newline
46
+ cannot slip through a ``$``."""
47
+
48
+ REGEX_METACHARS = frozenset("|()[]{}*+?^$")
49
+
50
+ MENTION_RE = re.compile(r"\[\[[^\[\]]+\]\]")
51
+
52
+ GREP_METACHAR_HINT = "pattern contains regex metacharacters — literal match returned nothing; retry with --regex?"
53
+
21
54
 
22
55
  def _add_wiki_arg(parser) -> None:
23
56
  parser.add_argument(
@@ -34,6 +67,95 @@ def _wiki_slug(args) -> str:
34
67
  return args.wiki
35
68
 
36
69
 
70
+ def _has_unescaped_metachar(pattern: str) -> bool:
71
+ """True when PATTERN carries regex syntax that a literal grep cannot honour.
72
+
73
+ Backslash-escaped characters are skipped: ``\\(`` is written by an author
74
+ who already knows the literal/regex distinction. ``.`` is deliberately not
75
+ in ``REGEX_METACHARS`` — it is too common in ordinary prose and in the
76
+ filenames agents grep for (``wiki_service.py``) to signal regex intent, and
77
+ a hint that cries wolf on every dotted token is worse than no hint.
78
+ """
79
+ chars = iter(pattern)
80
+ for char in chars:
81
+ if char == "\\":
82
+ next(chars, None)
83
+ continue
84
+ if char in REGEX_METACHARS:
85
+ return True
86
+ return False
87
+
88
+
89
+ def _is_valid_regex(pattern: str) -> bool:
90
+ """Whether PATTERN would survive the ``re.compile`` the server runs.
91
+
92
+ ``re.compile`` is the only way to ask — there is no ``re.is_valid`` — so the
93
+ expected-to-raise probe is the API, not defensive wrapping. The answer is
94
+ exact rather than heuristic: ``wiki_service.grep_pages`` compiles with this
95
+ same stdlib engine, and compiles it *unguarded*, so advising ``--regex`` on
96
+ a pattern that cannot compile would trade a clean exit-0 empty result for an
97
+ unhandled ``re.error``.
98
+
99
+ ``re.error`` is not the whole failure contract: a repeat count above
100
+ ``MAXREPEAT`` (``a{4294967296}``) raises ``OverflowError`` and deep nesting
101
+ raises ``RecursionError``. Both must answer "no" rather than escape — a
102
+ probe that crashes the client on the *literal* path would be a worse bug
103
+ than the one this gate exists to prevent, and the backend would raise the
104
+ same thing in regex mode anyway.
105
+
106
+ Warnings are silenced because this compile is a *probe*, not a use: ``re``
107
+ writes ``FutureWarning: Possible nested set`` to stderr for patterns like
108
+ ``[[a]]``, which would pollute the very stream the hint contract keeps
109
+ clean. Only the caller's own hint may reach stderr here.
110
+ """
111
+ with warnings.catch_warnings():
112
+ warnings.simplefilter("ignore")
113
+ try:
114
+ re.compile(pattern)
115
+ except (re.error, OverflowError, RecursionError):
116
+ return False
117
+ return True
118
+
119
+
120
+ def _looks_like_mention(pattern: str) -> bool:
121
+ """Whether PATTERN contains Takt mention syntax — ``[[takt-42]]``, ``[[wiki/path]]``.
122
+
123
+ Grepping for a mention is high-traffic (CLAUDE.md tells every agent to write
124
+ them), and a missing one is exactly the zero-hit path that arms the hint —
125
+ yet ``--regex`` is never the fix. ``[[takt/context/taktcli]]`` even compiles,
126
+ as a character class that quietly matches garbage, so the validity gate alone
127
+ would not catch it.
128
+
129
+ ``search``, not ``fullmatch``: ``[[a]]|[[b]]`` is two mentions and ``--regex``
130
+ turns it into char-class junk just the same, so any pattern *containing* a
131
+ mention is one whose author is thinking in mentions rather than regex. The
132
+ cost is that a genuine regex spliced onto a mention goes unhinted — cheap,
133
+ since silence is the safe default and the hint was only ever a nudge.
134
+ """
135
+ return MENTION_RE.search(pattern) is not None
136
+
137
+
138
+ def _should_hint_regex(pattern: str) -> bool:
139
+ """Whether a zero-hit literal PATTERN earns an "add ``--regex``" nudge.
140
+
141
+ Three gates, each closing off a way the nudge could mislead: the pattern must
142
+ carry regex syntax at all, must not be mention syntax that ``--regex`` cannot
143
+ help, and must actually compile.
144
+ """
145
+ return _has_unescaped_metachar(pattern) and not _looks_like_mention(pattern) and _is_valid_regex(pattern)
146
+
147
+
148
+ def _grep_metachar_hint(data) -> str:
149
+ """Advise ``--regex`` only when a metachar pattern actually found nothing.
150
+
151
+ Gating on an empty result set is what keeps this quiet: a literal pattern
152
+ that matched is not a mistake, however many metacharacters it contains.
153
+ """
154
+ if data.get("wikiGrep"):
155
+ return ""
156
+ return GREP_METACHAR_HINT
157
+
158
+
37
159
  def register(subparsers) -> None:
38
160
  """Register the ``wiki`` noun group and all its verbs."""
39
161
  group = subparsers.add_parser(
@@ -50,7 +172,14 @@ def register(subparsers) -> None:
50
172
  p = verbs.add_parser("search", help="search pages (text or semantic)")
51
173
  p.add_argument("query")
52
174
  p.add_argument("--semantic", action="store_true", help="semantic (embedding) search instead of full-text")
53
- p.add_argument("--limit", type=int, default=10)
175
+ p.add_argument("--limit", type=int, default=10, help="how many pages to return")
176
+ p.add_argument("--offset", type=int, default=0, help="skip this many pages first (not with --semantic)")
177
+ p.add_argument(
178
+ "--order-by",
179
+ choices=ORDER_FIELD_CHOICES,
180
+ help="order by this field (default: relevance when searching, else updatedAt)",
181
+ )
182
+ p.add_argument("--direction", choices=DIRECTION_CHOICES, help="sort direction (default: the field's natural one)")
54
183
  _add_wiki_arg(p)
55
184
  add_output_args(p)
56
185
  p.set_defaults(func=search)
@@ -160,8 +289,24 @@ def read(args) -> int:
160
289
 
161
290
 
162
291
  def search(args) -> int:
292
+ """Full-text (``wikiPages``) or semantic (``wikiSemanticSearch``) page search.
293
+
294
+ ``--limit`` is the count on both paths; it spells ``first`` on ``wikiPages``, which
295
+ pairs it with ``--offset`` to skip, and ``limit`` on the semantic op. The cursor
296
+ (``after``) is deliberately not surfaced as a flag — a shell caller has nowhere to keep
297
+ a cursor between invocations, which is the gap ``--offset`` exists to close. Reach for
298
+ ``gql`` if you need a cursor walk.
299
+
300
+ The ordering flags are gated client-side on the semantic path because the op there has
301
+ no argument to reject them with: passing ``--order-by`` to a request that cannot carry
302
+ it would silently return cosine-ranked rows as though the flag had applied. ``--offset``
303
+ is measured against its default rather than truthiness — 0 is the same request as
304
+ absent, so only a caller who actually asked to skip is refused.
305
+ """
163
306
  wiki = _wiki_slug(args)
164
307
  if args.semantic:
308
+ if args.order_by or args.direction or args.offset != 0:
309
+ raise ConfigError(SEMANTIC_ORDER_CONFLICT)
165
310
  document = (
166
311
  "query($wiki:String!,$query:String!,$limit:Int!){"
167
312
  "wikiSemanticSearch(wiki:$wiki,query:$query,limit:$limit){"
@@ -169,11 +314,21 @@ def search(args) -> int:
169
314
  )
170
315
  return run_op(args, document, {"wiki": wiki, "query": args.query, "limit": args.limit})
171
316
  document = (
172
- "query($wiki:String!,$search:String,$first:Int!){"
173
- "wikiPages(wiki:$wiki,search:$search,first:$first){"
174
- "totalCount nodes{path title summary status}}}"
317
+ "query($wiki:String!,$search:String,$first:Int!,$offset:Int!,"
318
+ "$orderBy:WikiPageOrderField,$direction:SortDirection){"
319
+ "wikiPages(wiki:$wiki,search:$search,first:$first,offset:$offset,"
320
+ "orderBy:$orderBy,direction:$direction){"
321
+ "totalCount order{field direction} nodes{path title summary status}}}"
175
322
  )
176
- return run_op(args, document, {"wiki": wiki, "search": args.query, "first": args.limit})
323
+ variables = {
324
+ "wiki": wiki,
325
+ "search": args.query,
326
+ "first": args.limit,
327
+ "offset": args.offset,
328
+ "orderBy": args.order_by,
329
+ "direction": args.direction,
330
+ }
331
+ return run_op(args, document, variables)
177
332
 
178
333
 
179
334
  def grep(args) -> int:
@@ -189,7 +344,10 @@ def grep(args) -> int:
189
344
  "context": args.context,
190
345
  "limit": args.limit,
191
346
  }
192
- return run_op(args, document, variables)
347
+ hint = None
348
+ if not args.regex and _should_hint_regex(args.pattern):
349
+ hint = _grep_metachar_hint
350
+ return run_op(args, document, variables, hint=hint)
193
351
 
194
352
 
195
353
  def create(args) -> int:
@@ -279,6 +437,8 @@ def bulk_status(args) -> int:
279
437
 
280
438
 
281
439
  def related(args) -> int:
440
+ if not TASK_IDENTIFIER_RE.fullmatch(args.task_id):
441
+ raise ConfigError("wiki related takes a task identifier (e.g. takt-42), not a page path")
282
442
  document = (
283
443
  "query($identifier:String!,$wiki:String!,$limit:Int!){"
284
444
  "findRelatedWikiPages(identifier:$identifier,wiki:$wiki,limit:$limit){"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taktcli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core
5
5
  Author: Takt
6
6
  License: MIT
@@ -139,7 +139,7 @@ Every MCP tool maps to a verb:
139
139
  | `get_task_comments` | `task comments <id>` |
140
140
  | `attach_result` | `task result <id>` |
141
141
  | `get_task_results` | `task results <id>` |
142
- | `delete_result` | `task result delete <id>` |
142
+ | `delete_result` | `task result-delete <id>` |
143
143
  | `wiki_read` | `wiki read <path>` |
144
144
  | `wiki_search` / `wiki_semantic_search` | `wiki search <q>` (`--semantic`) |
145
145
  | `wiki_grep` | `wiki grep <pattern>` |
@@ -43,6 +43,7 @@ tests/test_project.py
43
43
  tests/test_schema.py
44
44
  tests/test_task.py
45
45
  tests/test_transport.py
46
+ tests/test_version.py
46
47
  tests/test_watch.py
47
48
  tests/test_wiki.py
48
49
  tests/test_workspace.py
@@ -30,6 +30,23 @@ def patch(monkeypatch, *, envelope=None, capture=None, raises=None):
30
30
  return capture
31
31
 
32
32
 
33
+ def patch_calls(monkeypatch, envelopes):
34
+ """Stub auth + execute with a per-call envelope queue, recording every call.
35
+
36
+ ``create --parent`` spends two round-trips (resolve identifier -> createTask),
37
+ so these tests need each call's document/variables, not just the last one.
38
+ """
39
+ monkeypatch.setattr("taktcli.commands._exec.resolve_auth", lambda: (URL, TOKEN))
40
+ calls = []
41
+
42
+ def fake_execute(query, variables=None, *, url, token, **kwargs):
43
+ calls.append({"query": query, "variables": variables})
44
+ return envelopes[len(calls) - 1]
45
+
46
+ monkeypatch.setattr("taktcli.commands._exec.execute", fake_execute)
47
+ return calls
48
+
49
+
33
50
  def test_get_builds_task_query(monkeypatch, capsys):
34
51
  cap = patch(monkeypatch, envelope={"data": {"task": {"identifier": "takt-1"}}}, capture={})
35
52
  assert main(["task", "get", "takt-1"]) == 0
@@ -101,6 +118,63 @@ def test_create_desc_and_desc_file_conflict_exits_3(monkeypatch, capsys, tmp_pat
101
118
  assert "not both" in capsys.readouterr().err
102
119
 
103
120
 
121
+ def test_create_parent_resolves_identifier_to_uuid(monkeypatch):
122
+ calls = patch_calls(monkeypatch, [
123
+ {"data": {"task": {"id": "3f1c-uuid-10"}}},
124
+ {"data": {"createTask": {"identifier": "takt-11"}}},
125
+ ])
126
+ assert main([
127
+ "task", "create", "--project", "takt", "--title", "x",
128
+ "--parent", "takt-10", "--status", "PENDING",
129
+ ]) == 0
130
+ assert len(calls) == 2
131
+ assert "task(identifier: $id) { id }" in calls[0]["query"]
132
+ assert calls[0]["variables"] == {"id": "takt-10"}
133
+ assert calls[1]["variables"]["input"] == {
134
+ "title": "x",
135
+ "parentId": "3f1c-uuid-10",
136
+ "status": "PENDING",
137
+ }
138
+
139
+
140
+ def test_create_parent_id_passes_uuid_through_without_lookup(monkeypatch):
141
+ calls = patch_calls(monkeypatch, [{"data": {"createTask": {"identifier": "takt-11"}}}])
142
+ assert main(["task", "create", "--title", "x", "--parent-id", "3f1c-uuid-10"]) == 0
143
+ assert len(calls) == 1
144
+ assert "createTask" in calls[0]["query"]
145
+ assert calls[0]["variables"]["input"] == {"title": "x", "parentId": "3f1c-uuid-10"}
146
+
147
+
148
+ def test_create_parent_and_parent_id_conflict_exits_3(monkeypatch, capsys):
149
+ calls = patch_calls(monkeypatch, [])
150
+ assert main([
151
+ "task", "create", "--title", "x",
152
+ "--parent", "takt-10", "--parent-id", "3f1c-uuid-10",
153
+ ]) == 3
154
+ assert "not both" in capsys.readouterr().err
155
+ assert calls == []
156
+
157
+
158
+ @pytest.mark.parametrize("flag", ["--parent", "--parent-id"])
159
+ def test_create_empty_parent_flag_exits_3_without_creating(monkeypatch, capsys, flag):
160
+ """An empty parent value must not silently create a parentless task.
161
+
162
+ ``createTask`` coerces a falsy ``parentId`` to ``None``, so an unguarded
163
+ empty string reaches the server and parents nothing at exit 0.
164
+ """
165
+ calls = patch_calls(monkeypatch, [])
166
+ assert main(["task", "create", "--title", "x", flag, ""]) == 3
167
+ assert f"{flag}: empty value" in capsys.readouterr().err
168
+ assert calls == []
169
+
170
+
171
+ def test_create_parent_unknown_identifier_exits_3_before_creating(monkeypatch, capsys):
172
+ calls = patch_calls(monkeypatch, [{"data": {"task": None}}])
173
+ assert main(["task", "create", "--title", "x", "--parent", "takt-999"]) == 3
174
+ assert "no task found with identifier 'takt-999'" in capsys.readouterr().err
175
+ assert len(calls) == 1
176
+
177
+
104
178
  def test_update_sets_fields(monkeypatch):
105
179
  cap = patch(monkeypatch, capture={})
106
180
  main(["task", "update", "takt-1", "--title", "New", "--label-add", "dx"])
@@ -0,0 +1,44 @@
1
+ """__version__ is single-sourced from distribution metadata, with a dev fallback."""
2
+
3
+ import importlib.metadata
4
+
5
+ import pytest
6
+
7
+ import taktcli
8
+ from taktcli import _resolve_version
9
+ from taktcli.__main__ import main
10
+
11
+
12
+ def test_resolve_version_reads_the_taktcli_distribution(monkeypatch):
13
+ queried = []
14
+
15
+ def fake_version(name):
16
+ queried.append(name)
17
+ return "9.9.9"
18
+
19
+ monkeypatch.setattr(taktcli, "version", fake_version)
20
+ assert _resolve_version() == "9.9.9"
21
+ assert queried == ["taktcli"]
22
+
23
+
24
+ def test_resolve_version_falls_back_when_not_installed(monkeypatch):
25
+ def not_installed(name):
26
+ raise importlib.metadata.PackageNotFoundError(name)
27
+
28
+ monkeypatch.setattr(taktcli, "version", not_installed)
29
+ assert _resolve_version() == "0.0.0.dev0"
30
+
31
+
32
+ def test_version_matches_installed_distribution():
33
+ try:
34
+ installed = importlib.metadata.version("taktcli")
35
+ except importlib.metadata.PackageNotFoundError:
36
+ pytest.skip("taktcli is not pip-installed in this environment")
37
+ assert taktcli.__version__ == installed
38
+
39
+
40
+ def test_version_flag_reports_resolved_version(capsys):
41
+ with pytest.raises(SystemExit) as exc:
42
+ main(["--version"])
43
+ assert exc.value.code == 0
44
+ assert capsys.readouterr().out.strip() == f"taktcli {taktcli.__version__}"