taktcli 0.1.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 (50) hide show
  1. taktcli-0.1.0/PKG-INFO +117 -0
  2. taktcli-0.1.0/README.md +101 -0
  3. taktcli-0.1.0/pyproject.toml +31 -0
  4. taktcli-0.1.0/setup.cfg +4 -0
  5. taktcli-0.1.0/taktcli/__init__.py +6 -0
  6. taktcli-0.1.0/taktcli/__main__.py +53 -0
  7. taktcli-0.1.0/taktcli/auth.py +104 -0
  8. taktcli-0.1.0/taktcli/commands/__init__.py +68 -0
  9. taktcli-0.1.0/taktcli/commands/_exec.py +85 -0
  10. taktcli-0.1.0/taktcli/commands/_run.py +51 -0
  11. taktcli-0.1.0/taktcli/commands/auth.py +206 -0
  12. taktcli-0.1.0/taktcli/commands/file.py +183 -0
  13. taktcli-0.1.0/taktcli/commands/gql.py +66 -0
  14. taktcli-0.1.0/taktcli/commands/hotpath.py +150 -0
  15. taktcli-0.1.0/taktcli/commands/label.py +89 -0
  16. taktcli-0.1.0/taktcli/commands/notif.py +64 -0
  17. taktcli-0.1.0/taktcli/commands/project.py +111 -0
  18. taktcli-0.1.0/taktcli/commands/schema.py +56 -0
  19. taktcli-0.1.0/taktcli/commands/task.py +418 -0
  20. taktcli-0.1.0/taktcli/commands/watch.py +51 -0
  21. taktcli-0.1.0/taktcli/commands/wiki.py +308 -0
  22. taktcli-0.1.0/taktcli/commands/workspace.py +59 -0
  23. taktcli-0.1.0/taktcli/errors.py +76 -0
  24. taktcli-0.1.0/taktcli/introspection.py +253 -0
  25. taktcli-0.1.0/taktcli/io.py +150 -0
  26. taktcli-0.1.0/taktcli/transport.py +136 -0
  27. taktcli-0.1.0/taktcli/watch.py +169 -0
  28. taktcli-0.1.0/taktcli.egg-info/PKG-INFO +117 -0
  29. taktcli-0.1.0/taktcli.egg-info/SOURCES.txt +48 -0
  30. taktcli-0.1.0/taktcli.egg-info/dependency_links.txt +1 -0
  31. taktcli-0.1.0/taktcli.egg-info/entry_points.txt +2 -0
  32. taktcli-0.1.0/taktcli.egg-info/requires.txt +6 -0
  33. taktcli-0.1.0/taktcli.egg-info/top_level.txt +1 -0
  34. taktcli-0.1.0/tests/test_auth.py +75 -0
  35. taktcli-0.1.0/tests/test_auth_commands.py +246 -0
  36. taktcli-0.1.0/tests/test_cli.py +81 -0
  37. taktcli-0.1.0/tests/test_file.py +187 -0
  38. taktcli-0.1.0/tests/test_gql.py +115 -0
  39. taktcli-0.1.0/tests/test_hotpath.py +157 -0
  40. taktcli-0.1.0/tests/test_introspection.py +175 -0
  41. taktcli-0.1.0/tests/test_io.py +122 -0
  42. taktcli-0.1.0/tests/test_label.py +100 -0
  43. taktcli-0.1.0/tests/test_notif.py +61 -0
  44. taktcli-0.1.0/tests/test_project.py +106 -0
  45. taktcli-0.1.0/tests/test_schema.py +107 -0
  46. taktcli-0.1.0/tests/test_task.py +278 -0
  47. taktcli-0.1.0/tests/test_transport.py +123 -0
  48. taktcli-0.1.0/tests/test_watch.py +287 -0
  49. taktcli-0.1.0/tests/test_wiki.py +219 -0
  50. taktcli-0.1.0/tests/test_workspace.py +65 -0
taktcli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: taktcli
3
+ Version: 0.1.0
4
+ Summary: Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core
5
+ Author: Takt
6
+ License: MIT
7
+ Project-URL: Homepage, https://takt.sh
8
+ Project-URL: Repository, https://github.com/muzhig/takt
9
+ Keywords: takt,graphql,cli,agents
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ Provides-Extra: watch
13
+ Requires-Dist: websockets>=12.0; extra == "watch"
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8.0; extra == "dev"
16
+
17
+ # taktcli
18
+
19
+ Authorized GraphQL CLI for [Takt](https://takt.sh) — the full GraphQL surface
20
+ from the shell, for agents and humans. Package and console-script are both
21
+ named `taktcli` (bare `takt` is taken on PyPI).
22
+
23
+ The core is **stdlib-only** (`urllib` + `argparse` + `json`): `argv → GraphQL-over-HTTP`.
24
+ It sidesteps the MCP client-serialization bug class entirely — argv strings can't
25
+ be double-JSON-encoded. See PRD 002 in the Takt wiki (`takt/dev/prd-002-taktcli`).
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install taktcli # stdlib-only core
31
+ pip install 'taktcli[watch]' # + websockets, enables `taktcli watch`
32
+ ```
33
+
34
+ From a checkout (monorepo subdir):
35
+
36
+ ```bash
37
+ pip install -e cli/
38
+ ```
39
+
40
+ ## Auth (PRD §6)
41
+
42
+ Resolution order, simplest-wins:
43
+
44
+ - **Token:** `TAKT_TOKEN` env → `~/.takt/credentials.json` → error `Run: taktcli login` (exit 3)
45
+ - **URL:** `TAKT_URL` env → `url` in the credentials file → default `https://api.takt.sh/graphql`
46
+
47
+ `~/.takt/credentials.json` is plain JSON (no YAML dependency):
48
+
49
+ ```json
50
+ { "token": "<jwt>", "url": "https://api.takt.sh/graphql" }
51
+ ```
52
+
53
+ ### Login
54
+
55
+ `taktcli login` runs the OAuth 2.0 device-authorization grant: it prints a
56
+ verification URL + user code, polls until you approve in the browser, then
57
+ writes `{token, url}` to `~/.takt/credentials.json` (mode `0600`).
58
+
59
+ ```bash
60
+ taktcli login # default endpoint
61
+ taktcli login --url http://localhost:8454/graphql # override the endpoint
62
+ taktcli logout # remove the stored credential
63
+ taktcli whoami # nickname + claims + active source
64
+ ```
65
+
66
+ `--url` (highest) → `TAKT_URL` env → default decides where `login` authenticates.
67
+ `whoami` reports which credential source is active (`TAKT_TOKEN` env vs the file).
68
+
69
+ ## Exit codes (PRD §4.3)
70
+
71
+ | Code | Meaning |
72
+ |------|---------|
73
+ | `0` | success |
74
+ | `1` | GraphQL error (server reached; `errors[]` non-empty) |
75
+ | `2` | transport error (DNS/TCP/TLS/HTTP non-2xx/timeout) |
76
+ | `3` | config or usage error (no token, bad URL, unknown verb, missing flag) |
77
+
78
+ `extensions.code` (e.g. `LEASE_EXPIRED`, `ALREADY_CLAIMED`) is preserved in
79
+ error output so loops can branch on it.
80
+
81
+ ## Shared I/O flags (PRD §4)
82
+
83
+ - `--var KEY=VALUE` (repeatable; value parsed as JSON when valid, else string)
84
+ - `--vars JSON` / `--vars-file PATH` (`-` = stdin) — bulk variables
85
+ - `--field-file NAME=PATH` (`NAME=-` = stdin) — inject a file's contents as a
86
+ variable; the canonical path for large markdown
87
+ - `--raw` — full envelope `{data, errors, extensions}`; default is pretty JSON of `.data`
88
+ - `--compact` — single-line JSON
89
+
90
+ Variable precedence (lowest → highest): `--vars-file` < `--vars` < `--var` < `--field-file`.
91
+
92
+ ## Status
93
+
94
+ Implemented: **A** (foundation — package skeleton, transport, auth/URL
95
+ resolution, shared I/O plumbing, CI/release), **B** (`gql` + `schema`
96
+ passthrough), **C** (hot-path aliases + `task …`), **D** (`wiki …` +
97
+ `workspace …`), **G** (`login`/`logout`/`whoami`), and **H** (`label …`,
98
+ `notif …`, `project …`). Curated verbs each map to a GraphQL op via
99
+ `transport.execute`; the `wiki`/`workspace` verbs take a global `--wiki SLUG`
100
+ (default `$TAKT_WIKI`) and stream big markdown through
101
+ `--content-file`/`--old-file`/`--new-file` (`-` = stdin).
102
+
103
+ `label create` is CLI-only (the MCP server never wrapped `createLabel`, but the
104
+ GraphQL mutation exists). `label update`/`delete` are keyed by **name** and
105
+ resolve the id client-side via the `labels` query, mirroring the MCP tools.
106
+ `project rename` is name-only — slug changes are blocked on backend
107
+ [[takt-304]]. Per the [[takt-83]] guardrail there is **no** claims/permission
108
+ verb anywhere in the surface.
109
+
110
+ The remaining verb groups are stubs — running one exits 3 with a pointer to its
111
+ implementing subtask (E media/file, F watch, J delete-result).
112
+
113
+ ## Release
114
+
115
+ Push a `cli-v*` tag (e.g. `cli-v0.1.0`) — `.github/workflows/cli.yaml` builds
116
+ `cli/` and OIDC trusted-publishes to PyPI. The tag must match the version in
117
+ `cli/pyproject.toml` (CI asserts this).
@@ -0,0 +1,101 @@
1
+ # taktcli
2
+
3
+ Authorized GraphQL CLI for [Takt](https://takt.sh) — the full GraphQL surface
4
+ from the shell, for agents and humans. Package and console-script are both
5
+ named `taktcli` (bare `takt` is taken on PyPI).
6
+
7
+ The core is **stdlib-only** (`urllib` + `argparse` + `json`): `argv → GraphQL-over-HTTP`.
8
+ It sidesteps the MCP client-serialization bug class entirely — argv strings can't
9
+ be double-JSON-encoded. See PRD 002 in the Takt wiki (`takt/dev/prd-002-taktcli`).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install taktcli # stdlib-only core
15
+ pip install 'taktcli[watch]' # + websockets, enables `taktcli watch`
16
+ ```
17
+
18
+ From a checkout (monorepo subdir):
19
+
20
+ ```bash
21
+ pip install -e cli/
22
+ ```
23
+
24
+ ## Auth (PRD §6)
25
+
26
+ Resolution order, simplest-wins:
27
+
28
+ - **Token:** `TAKT_TOKEN` env → `~/.takt/credentials.json` → error `Run: taktcli login` (exit 3)
29
+ - **URL:** `TAKT_URL` env → `url` in the credentials file → default `https://api.takt.sh/graphql`
30
+
31
+ `~/.takt/credentials.json` is plain JSON (no YAML dependency):
32
+
33
+ ```json
34
+ { "token": "<jwt>", "url": "https://api.takt.sh/graphql" }
35
+ ```
36
+
37
+ ### Login
38
+
39
+ `taktcli login` runs the OAuth 2.0 device-authorization grant: it prints a
40
+ verification URL + user code, polls until you approve in the browser, then
41
+ writes `{token, url}` to `~/.takt/credentials.json` (mode `0600`).
42
+
43
+ ```bash
44
+ taktcli login # default endpoint
45
+ taktcli login --url http://localhost:8454/graphql # override the endpoint
46
+ taktcli logout # remove the stored credential
47
+ taktcli whoami # nickname + claims + active source
48
+ ```
49
+
50
+ `--url` (highest) → `TAKT_URL` env → default decides where `login` authenticates.
51
+ `whoami` reports which credential source is active (`TAKT_TOKEN` env vs the file).
52
+
53
+ ## Exit codes (PRD §4.3)
54
+
55
+ | Code | Meaning |
56
+ |------|---------|
57
+ | `0` | success |
58
+ | `1` | GraphQL error (server reached; `errors[]` non-empty) |
59
+ | `2` | transport error (DNS/TCP/TLS/HTTP non-2xx/timeout) |
60
+ | `3` | config or usage error (no token, bad URL, unknown verb, missing flag) |
61
+
62
+ `extensions.code` (e.g. `LEASE_EXPIRED`, `ALREADY_CLAIMED`) is preserved in
63
+ error output so loops can branch on it.
64
+
65
+ ## Shared I/O flags (PRD §4)
66
+
67
+ - `--var KEY=VALUE` (repeatable; value parsed as JSON when valid, else string)
68
+ - `--vars JSON` / `--vars-file PATH` (`-` = stdin) — bulk variables
69
+ - `--field-file NAME=PATH` (`NAME=-` = stdin) — inject a file's contents as a
70
+ variable; the canonical path for large markdown
71
+ - `--raw` — full envelope `{data, errors, extensions}`; default is pretty JSON of `.data`
72
+ - `--compact` — single-line JSON
73
+
74
+ Variable precedence (lowest → highest): `--vars-file` < `--vars` < `--var` < `--field-file`.
75
+
76
+ ## Status
77
+
78
+ Implemented: **A** (foundation — package skeleton, transport, auth/URL
79
+ resolution, shared I/O plumbing, CI/release), **B** (`gql` + `schema`
80
+ passthrough), **C** (hot-path aliases + `task …`), **D** (`wiki …` +
81
+ `workspace …`), **G** (`login`/`logout`/`whoami`), and **H** (`label …`,
82
+ `notif …`, `project …`). Curated verbs each map to a GraphQL op via
83
+ `transport.execute`; the `wiki`/`workspace` verbs take a global `--wiki SLUG`
84
+ (default `$TAKT_WIKI`) and stream big markdown through
85
+ `--content-file`/`--old-file`/`--new-file` (`-` = stdin).
86
+
87
+ `label create` is CLI-only (the MCP server never wrapped `createLabel`, but the
88
+ GraphQL mutation exists). `label update`/`delete` are keyed by **name** and
89
+ resolve the id client-side via the `labels` query, mirroring the MCP tools.
90
+ `project rename` is name-only — slug changes are blocked on backend
91
+ [[takt-304]]. Per the [[takt-83]] guardrail there is **no** claims/permission
92
+ verb anywhere in the surface.
93
+
94
+ The remaining verb groups are stubs — running one exits 3 with a pointer to its
95
+ implementing subtask (E media/file, F watch, J delete-result).
96
+
97
+ ## Release
98
+
99
+ Push a `cli-v*` tag (e.g. `cli-v0.1.0`) — `.github/workflows/cli.yaml` builds
100
+ `cli/` and OIDC trusted-publishes to PyPI. The tag must match the version in
101
+ `cli/pyproject.toml` (CI asserts this).
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "taktcli"
3
+ version = "0.1.0"
4
+ description = "Authorized GraphQL CLI for Takt — full GraphQL surface from the shell, stdlib-only core"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Takt" }]
9
+ keywords = ["takt", "graphql", "cli", "agents"]
10
+ dependencies = []
11
+
12
+ [project.optional-dependencies]
13
+ watch = ["websockets>=12.0"]
14
+ dev = ["pytest>=8.0"]
15
+
16
+ [project.scripts]
17
+ taktcli = "taktcli.__main__:main"
18
+
19
+ [project.urls]
20
+ Homepage = "https://takt.sh"
21
+ Repository = "https://github.com/muzhig/takt"
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["taktcli*"]
28
+
29
+ [build-system]
30
+ requires = ["setuptools>=75"]
31
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
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
+ __version__ = "0.1.0"
@@ -0,0 +1,53 @@
1
+ """taktcli entry point — argparse dispatch (PRD 002 §3, §4.3).
2
+
3
+ Resolves the command tree, runs the selected handler, and maps typed errors to
4
+ the exit-code contract. Usage/parse errors exit 3 (config/usage), not argparse's
5
+ default 2 (which the contract reserves for transport failures).
6
+ """
7
+
8
+ import argparse
9
+ import sys
10
+
11
+ from . import __version__, commands
12
+ from .errors import TaktCliError
13
+
14
+
15
+ class TaktArgumentParser(argparse.ArgumentParser):
16
+ """ArgumentParser whose parse/usage errors exit 3, per the CLI contract."""
17
+
18
+ def error(self, message):
19
+ self.print_usage(sys.stderr)
20
+ self.exit(3, f"{self.prog}: error: {message}\n")
21
+
22
+
23
+ def build_parser() -> TaktArgumentParser:
24
+ """Build the top-level parser with all noun groups and verbs registered."""
25
+ parser = TaktArgumentParser(
26
+ prog="taktcli",
27
+ description="Authorized GraphQL CLI for Takt — full GraphQL surface from the shell.",
28
+ epilog="Run 'taktcli <noun> --help' for a group's verbs. Most verbs land in subtasks B-J.",
29
+ )
30
+ parser.add_argument("--version", action="version", version=f"taktcli {__version__}")
31
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>", required=True)
32
+ commands.register(subparsers)
33
+ return parser
34
+
35
+
36
+ def main(argv=None) -> int:
37
+ """Parse argv, dispatch to the handler, and return the process exit code."""
38
+ parser = build_parser()
39
+ args = parser.parse_args(argv)
40
+ handler = getattr(args, "func", None)
41
+ if handler is None:
42
+ parser.print_help(sys.stderr)
43
+ return 3
44
+ try:
45
+ result = handler(args)
46
+ except TaktCliError as exc:
47
+ print(exc.render(), file=sys.stderr)
48
+ return exc.exit_code
49
+ return int(result or 0)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ sys.exit(main())
@@ -0,0 +1,104 @@
1
+ """Auth + URL resolution (PRD 002 §6).
2
+
3
+ Resolution order (simplest-wins):
4
+
5
+ Token: ``TAKT_TOKEN`` env -> ``~/.takt/credentials.json`` -> error (exit 3)
6
+ URL: ``TAKT_URL`` env -> ``url`` in credentials file -> default
7
+
8
+ Full ``taktcli login`` (device-auth) is subtask G2; this module only reads
9
+ the JSON credential store and resolves the active token/URL.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ from .errors import ConfigError
17
+
18
+ DEFAULT_URL = "https://api.takt.sh/graphql"
19
+
20
+ LOGIN_HINT = "No Takt credentials found. Run: taktcli login"
21
+
22
+
23
+ def credentials_path() -> Path:
24
+ """Location of the JSON credential store (``~/.takt/credentials.json``)."""
25
+ return Path.home() / ".takt" / "credentials.json"
26
+
27
+
28
+ def load_credentials(path: Path | None = None) -> dict:
29
+ """Read the JSON credential store. Missing file -> ``{}``.
30
+
31
+ A present-but-corrupt file is a configuration error (exit 3), not a
32
+ silent empty read — surface it so the user can fix the file.
33
+ """
34
+ path = path or credentials_path()
35
+ if not path.exists():
36
+ return {}
37
+ raw = path.read_text()
38
+ try:
39
+ data = json.loads(raw)
40
+ except json.JSONDecodeError as exc:
41
+ raise ConfigError(f"Corrupt credentials file at {path}: {exc}") from exc
42
+ if not isinstance(data, dict):
43
+ raise ConfigError(f"Credentials file at {path} must be a JSON object")
44
+ return data
45
+
46
+
47
+ def resolve_token(credentials: dict | None = None) -> str:
48
+ """Resolve the bearer token: env -> file -> error (exit 3)."""
49
+ env_token = os.environ.get("TAKT_TOKEN")
50
+ if env_token:
51
+ return env_token
52
+ creds = load_credentials() if credentials is None else credentials
53
+ token = creds.get("token")
54
+ if token:
55
+ return token
56
+ raise ConfigError(LOGIN_HINT)
57
+
58
+
59
+ def resolve_url(credentials: dict | None = None) -> str:
60
+ """Resolve the GraphQL endpoint: env -> file -> default."""
61
+ env_url = os.environ.get("TAKT_URL")
62
+ if env_url:
63
+ return env_url
64
+ creds = load_credentials() if credentials is None else credentials
65
+ return creds.get("url") or DEFAULT_URL
66
+
67
+
68
+ def resolve_auth() -> tuple[str, str]:
69
+ """Resolve ``(url, token)`` in one pass, reading the file at most once."""
70
+ creds = load_credentials()
71
+ return resolve_url(creds), resolve_token(creds)
72
+
73
+
74
+ def token_source() -> str | None:
75
+ """Describe where the active token comes from, or ``None`` if there is none.
76
+
77
+ Mirrors :func:`resolve_token`'s precedence: env wins over the file.
78
+ """
79
+ if os.environ.get("TAKT_TOKEN"):
80
+ return "TAKT_TOKEN env"
81
+ if load_credentials().get("token"):
82
+ return f"credentials file ({credentials_path()})"
83
+ return None
84
+
85
+
86
+ def save_credentials(token: str, url: str, path: Path | None = None) -> Path:
87
+ """Persist ``{token, url}`` to the JSON store, creating ``~/.takt`` as 0700.
88
+
89
+ The file is written 0600 — it holds a bearer token.
90
+ """
91
+ path = path or credentials_path()
92
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
93
+ path.write_text(json.dumps({"token": token, "url": url}, indent=2) + "\n")
94
+ path.chmod(0o600)
95
+ return path
96
+
97
+
98
+ def clear_credentials(path: Path | None = None) -> bool:
99
+ """Remove the credential store. Returns ``True`` if a file was deleted."""
100
+ path = path or credentials_path()
101
+ if path.exists():
102
+ path.unlink()
103
+ return True
104
+ return False
@@ -0,0 +1,68 @@
1
+ """Command surface for taktcli (PRD 002 §3).
2
+
3
+ This module declares the noun/verb tree. Implemented surfaces register their own
4
+ parsers via per-module ``register`` functions; the remaining groups are stubs
5
+ (running one exits 3 with a pointer to the implementing subtask) until they land.
6
+ """
7
+
8
+ from ..errors import ConfigError
9
+ from ..io import add_shared_io_args
10
+ from . import auth, file, gql, hotpath, label, notif, project, schema, task, watch, wiki, workspace
11
+
12
+ NOUN_GROUPS = {}
13
+
14
+
15
+ def _stub(path: str, subtask: str):
16
+ def handler(args):
17
+ raise ConfigError(
18
+ f"`taktcli {path}` is not implemented yet (subtask {subtask})."
19
+ )
20
+
21
+ return handler
22
+
23
+
24
+ def _add_leaf(subparsers, name: str, spec, path_prefix: str) -> None:
25
+ help_text = spec["help"] if isinstance(spec, dict) else spec
26
+ subtask = spec.get("subtask", "?") if isinstance(spec, dict) else "?"
27
+ leaf = subparsers.add_parser(name, help=help_text, description=help_text)
28
+ path = f"{path_prefix}{name}".strip()
29
+ if isinstance(spec, dict) and spec.get("io"):
30
+ add_shared_io_args(leaf)
31
+ else:
32
+ leaf.add_argument("args", nargs="*", help="(stub — see implementing subtask)")
33
+ leaf.set_defaults(func=_stub(path, subtask))
34
+
35
+
36
+ def _add_group(subparsers, name: str, spec: dict, path_prefix: str, parent_subtask: str = "?") -> None:
37
+ group = subparsers.add_parser(name, help=spec["help"], description=spec["help"])
38
+ sub = group.add_subparsers(dest=f"{name}_verb", metavar="<verb>", required=True)
39
+ group_subtask = spec.get("subtask", parent_subtask)
40
+ for verb, verb_spec in spec["verbs"].items():
41
+ path = f"{path_prefix}{name} "
42
+ if isinstance(verb_spec, dict) and "verbs" in verb_spec:
43
+ _add_group(sub, verb, verb_spec, path, group_subtask)
44
+ else:
45
+ leaf_spec = (
46
+ {"help": verb_spec, "subtask": group_subtask}
47
+ if isinstance(verb_spec, str)
48
+ else {**verb_spec, "subtask": verb_spec.get("subtask", group_subtask)}
49
+ )
50
+ _add_leaf(sub, verb, leaf_spec, path)
51
+
52
+
53
+ def register(subparsers) -> None:
54
+ """Register every implemented verb plus the remaining stub groups."""
55
+ gql.register(subparsers)
56
+ schema.register(subparsers)
57
+ auth.register(subparsers)
58
+ hotpath.register(subparsers)
59
+ task.register(subparsers)
60
+ file.register(subparsers)
61
+ wiki.register(subparsers)
62
+ workspace.register(subparsers)
63
+ label.register(subparsers)
64
+ notif.register(subparsers)
65
+ project.register(subparsers)
66
+ watch.register(subparsers)
67
+ for name, spec in NOUN_GROUPS.items():
68
+ _add_group(subparsers, name, spec, "")
@@ -0,0 +1,85 @@
1
+ """Shared execution + I/O helpers for the task verbs and hot-path aliases.
2
+
3
+ Every verb in subtask C builds a GraphQL document plus a variables dict and runs
4
+ it through :func:`run_operation` (or :func:`fetch` when it needs the raw envelope
5
+ back). Auth, transport, output formatting, and the exit-code contract are all
6
+ funnelled through here so the verbs stay declarative (PRD 002 §3.3, §3.4, §4).
7
+ """
8
+
9
+ import argparse
10
+ import os
11
+ import sys
12
+
13
+ from ..auth import resolve_auth
14
+ from ..errors import ConfigError
15
+ from ..io import format_output, read_path
16
+ from ..transport import execute
17
+
18
+
19
+ def add_output_args(parser: argparse.ArgumentParser) -> None:
20
+ """Attach the ``--raw``/``--compact`` output flags to a verb parser."""
21
+ group = parser.add_argument_group("output")
22
+ group.add_argument(
23
+ "--raw",
24
+ action="store_true",
25
+ help="print the full GraphQL envelope {data, errors, extensions}",
26
+ )
27
+ group.add_argument(
28
+ "--compact",
29
+ action="store_true",
30
+ help="single-line JSON output (pipe-friendly)",
31
+ )
32
+
33
+
34
+ def fetch(document: str, variables: dict) -> dict:
35
+ """Resolve auth, run the operation, and return the full GraphQL envelope."""
36
+ url, token = resolve_auth()
37
+ return execute(document, variables, url=url, token=token)
38
+
39
+
40
+ def run_operation(args, document: str, variables: dict) -> int:
41
+ """Execute ``document`` and print the formatted envelope; return exit 0."""
42
+ envelope = fetch(document, variables)
43
+ print(format_output(envelope, raw=getattr(args, "raw", False), compact=getattr(args, "compact", False)))
44
+ sys.stdout.flush()
45
+ return 0
46
+
47
+
48
+ def resolve_lease(args) -> str | None:
49
+ """Resolve a lease id: ``--lease`` flag, else ``$TAKT_LEASE``, else None."""
50
+ lease = getattr(args, "lease", None)
51
+ if lease:
52
+ return lease
53
+ return os.environ.get("TAKT_LEASE") or None
54
+
55
+
56
+ def text_from_flags(inline, path, inline_flag: str, file_flag: str):
57
+ """Resolve a text value from an inline flag or a ``*-file`` path.
58
+
59
+ ``path`` of ``-`` reads stdin. Passing both is a usage error (exit 3).
60
+ Returns ``None`` when neither is provided so callers can decide if the
61
+ field is required.
62
+ """
63
+ if inline is not None and path is not None:
64
+ raise ConfigError(f"pass either {inline_flag} or {file_flag}, not both")
65
+ if path is not None:
66
+ return read_path(path)
67
+ return inline
68
+
69
+
70
+ def build_operation(name: str, field: str, arg_specs: list, selection: str, *, op_type: str = "query"):
71
+ """Assemble a single-field GraphQL operation from provided arguments.
72
+
73
+ ``arg_specs`` is a list of ``(var_name, gql_type, arg_name, value)`` tuples,
74
+ one per *provided* argument — only these are declared and passed, so the
75
+ document never references an unused (and therefore illegal) variable. The
76
+ ``selection`` string includes its own surrounding braces.
77
+ """
78
+ decls = ", ".join(f"${var}: {gql_type}" for var, gql_type, _, _ in arg_specs)
79
+ decl_str = f"({decls})" if decls else ""
80
+ field_args = ", ".join(f"{arg}: ${var}" for var, _, arg, _ in arg_specs)
81
+ field_args_str = f"({field_args})" if field_args else ""
82
+ selection_str = f" {selection}" if selection else ""
83
+ document = f"{op_type} {name}{decl_str} {{ {field}{field_args_str}{selection_str} }}"
84
+ variables = {var: value for var, _, _, value in arg_specs}
85
+ return document, variables
@@ -0,0 +1,51 @@
1
+ """Shared helpers for curated noun/verb commands (PRD 002 §3.4-§3.8).
2
+
3
+ Curated verbs map a fixed GraphQL document plus a small set of typed flags to
4
+ ``transport.execute`` and print the result through the §4.2 output contract.
5
+ This module holds the two pieces every curated verb repeats: running the op and
6
+ resolving an inline-or-file (``-`` = stdin) text argument.
7
+ """
8
+
9
+ import sys
10
+
11
+ from ..auth import resolve_auth
12
+ from ..errors import ConfigError
13
+ from ..io import format_output, read_path
14
+ from ..transport import execute
15
+
16
+
17
+ def run_op(args, document: str, variables: dict) -> int:
18
+ """Execute a GraphQL document and print the formatted result."""
19
+ url, token = resolve_auth()
20
+ envelope = execute(document, variables, url=url, token=token)
21
+ print(format_output(envelope, raw=getattr(args, "raw", False), compact=getattr(args, "compact", False)))
22
+ sys.stdout.flush()
23
+ return 0
24
+
25
+
26
+ def fetch_data(document: str, variables: dict) -> dict:
27
+ """Execute a GraphQL document and return its unwrapped ``.data`` payload.
28
+
29
+ For curated verbs that need a server lookup before their real op (e.g.
30
+ resolving a label name to its id) without printing the intermediate result.
31
+ """
32
+ url, token = resolve_auth()
33
+ envelope = execute(document, variables, url=url, token=token)
34
+ return (envelope or {}).get("data") or {}
35
+
36
+
37
+ def resolve_text(inline, file_path, name: str, *, required: bool = False):
38
+ """Resolve a ``--<name>`` / ``--<name>-file`` (`-` = stdin) pair.
39
+
40
+ Passing both is a usage error; passing neither yields ``None`` unless
41
+ ``required``, in which case it is a usage error too.
42
+ """
43
+ if inline is not None and file_path is not None:
44
+ raise ConfigError(f"pass either --{name} or --{name}-file, not both")
45
+ if file_path is not None:
46
+ return read_path(file_path)
47
+ if inline is not None:
48
+ return inline
49
+ if required:
50
+ raise ConfigError(f"--{name} or --{name}-file is required")
51
+ return None