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.
- {taktcli-0.2.0 → taktcli-0.3.0}/PKG-INFO +2 -2
- {taktcli-0.2.0 → taktcli-0.3.0}/README.md +1 -1
- {taktcli-0.2.0 → taktcli-0.3.0}/pyproject.toml +1 -1
- taktcli-0.3.0/taktcli/__init__.py +27 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/_run.py +14 -2
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/task.py +42 -3
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/wiki.py +166 -6
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/PKG-INFO +2 -2
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/SOURCES.txt +1 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_task.py +74 -0
- taktcli-0.3.0/tests/test_version.py +44 -0
- taktcli-0.3.0/tests/test_wiki.py +459 -0
- taktcli-0.2.0/taktcli/__init__.py +0 -6
- taktcli-0.2.0/tests/test_wiki.py +0 -219
- {taktcli-0.2.0 → taktcli-0.3.0}/setup.cfg +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/__main__.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/auth.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/__init__.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/_exec.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/auth.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/file.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/gql.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/hotpath.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/label.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/notif.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/project.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/schema.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/watch.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/commands/workspace.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/errors.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/introspection.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/io.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/transport.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli/watch.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/dependency_links.txt +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/entry_points.txt +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/requires.txt +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/taktcli.egg-info/top_level.txt +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_auth.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_auth_commands.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_cli.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_file.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_gql.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_hotpath.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_introspection.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_io.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_label.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_notif.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_project.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_schema.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_transport.py +0 -0
- {taktcli-0.2.0 → taktcli-0.3.0}/tests/test_watch.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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>` |
|
|
@@ -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.
|
|
126
|
-
payload["parentId"] = args.
|
|
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
|
|
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
|
-
"
|
|
174
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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>` |
|
|
@@ -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__}"
|