taktcli 0.1.0__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.
taktcli/__init__.py ADDED
@@ -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"
taktcli/__main__.py ADDED
@@ -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())
taktcli/auth.py ADDED
@@ -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
@@ -0,0 +1,206 @@
1
+ """``taktcli login``/``logout``/``whoami`` — auth verbs (PRD 002 §3.9, §6).
2
+
3
+ ``login`` drives the OAuth 2.0 device-authorization grant against the backend
4
+ (``requestDeviceCode`` -> poll ``pollDeviceToken`` until ``ISSUED``) and persists
5
+ ``{token, url}`` to ``~/.takt/credentials.json``. ``logout`` removes that file.
6
+ ``whoami`` runs the ``me`` query and reports which credential source is active.
7
+
8
+ The device-code request and poll are unauthenticated (no token exists yet), so
9
+ they post with an empty token; :mod:`..transport` omits the bearer header.
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import time
15
+ import urllib.request
16
+
17
+ from ..auth import (
18
+ DEFAULT_URL,
19
+ clear_credentials,
20
+ credentials_path,
21
+ resolve_auth,
22
+ save_credentials,
23
+ token_source,
24
+ )
25
+ from ..errors import ConfigError
26
+ from ..transport import execute
27
+
28
+ REQUEST_DEVICE_CODE = """
29
+ mutation RequestDeviceCode($clientName: String) {
30
+ requestDeviceCode(clientName: $clientName) {
31
+ deviceCode
32
+ userCode
33
+ verificationUri
34
+ verificationUriComplete
35
+ interval
36
+ expiresIn
37
+ }
38
+ }
39
+ """.strip()
40
+
41
+ POLL_DEVICE_TOKEN = """
42
+ mutation PollDeviceToken($deviceCode: String!) {
43
+ pollDeviceToken(deviceCode: $deviceCode) {
44
+ status
45
+ token
46
+ expiresAt
47
+ }
48
+ }
49
+ """.strip()
50
+
51
+ ME_QUERY = """
52
+ query Whoami {
53
+ me {
54
+ nickname
55
+ displayName
56
+ claims
57
+ }
58
+ }
59
+ """.strip()
60
+
61
+ SLOW_DOWN_INCREMENT = 5
62
+ DEFAULT_CLIENT_NAME = "taktcli"
63
+
64
+
65
+ def register(subparsers) -> None:
66
+ """Register the ``login``/``logout``/``whoami`` verbs on the top level."""
67
+ login_help = "device-auth login; stores ~/.takt/credentials.json"
68
+ login = subparsers.add_parser("login", help=login_help, description=login_help)
69
+ login.add_argument(
70
+ "--url",
71
+ metavar="URL",
72
+ help="GraphQL endpoint to authenticate against (overrides TAKT_URL and the default)",
73
+ )
74
+ login.add_argument(
75
+ "--client-name",
76
+ metavar="NAME",
77
+ default=DEFAULT_CLIENT_NAME,
78
+ help="client name recorded with the device authorization request",
79
+ )
80
+ login.set_defaults(func=run_login)
81
+
82
+ logout_help = "remove the stored credential"
83
+ logout = subparsers.add_parser("logout", help=logout_help, description=logout_help)
84
+ logout.set_defaults(func=run_logout)
85
+
86
+ whoami_help = "print the authenticated identity (nickname + claims + source)"
87
+ whoami = subparsers.add_parser("whoami", help=whoami_help, description=whoami_help)
88
+ whoami.set_defaults(func=run_whoami)
89
+
90
+
91
+ def _login_url(args) -> str:
92
+ """Resolve the endpoint for login: ``--url`` -> ``TAKT_URL`` -> default."""
93
+ return getattr(args, "url", None) or os.environ.get("TAKT_URL") or DEFAULT_URL
94
+
95
+
96
+ def device_login(url, *, client_name=None, opener=None, sleep=None, out=None) -> str:
97
+ """Run the device-authorization grant and return the issued token.
98
+
99
+ Prints the verification URL + user code to ``out``, then polls until the
100
+ code is approved (``ISSUED``). Honours the server ``interval`` and bumps it
101
+ on ``SLOW_DOWN``. Terminal failure statuses raise ``ConfigError`` (exit 3).
102
+
103
+ ``opener``/``sleep``/``out`` resolve to the live ``urlopen``/``time.sleep``/
104
+ ``stderr`` when ``None`` — so monkeypatching those modules drives the flow.
105
+ """
106
+ opener = opener or urllib.request.urlopen
107
+ sleep = sleep or time.sleep
108
+ out = out if out is not None else sys.stderr
109
+ envelope = execute(
110
+ REQUEST_DEVICE_CODE,
111
+ {"clientName": client_name},
112
+ url=url,
113
+ token=None,
114
+ opener=opener,
115
+ )
116
+ grant = (envelope.get("data") or {}).get("requestDeviceCode")
117
+ if not grant:
118
+ raise ConfigError(f"Device-code request returned no grant from {url}")
119
+
120
+ device_code = grant["deviceCode"]
121
+ user_code = grant["userCode"]
122
+ interval = grant.get("interval") or 5
123
+ complete = grant.get("verificationUriComplete") or grant.get("verificationUri")
124
+
125
+ print("To authorize taktcli, visit:", file=out)
126
+ print(f" {grant['verificationUri']}", file=out)
127
+ print(f"and enter the code: {user_code}", file=out)
128
+ if complete:
129
+ print(f"\nOr open this URL directly:\n {complete}", file=out)
130
+ print("\nWaiting for approval...", file=out)
131
+ out.flush()
132
+
133
+ while True:
134
+ sleep(interval)
135
+ envelope = execute(
136
+ POLL_DEVICE_TOKEN,
137
+ {"deviceCode": device_code},
138
+ url=url,
139
+ token=None,
140
+ opener=opener,
141
+ )
142
+ result = (envelope.get("data") or {}).get("pollDeviceToken") or {}
143
+ status = result.get("status")
144
+ if status == "AUTHORIZATION_PENDING":
145
+ continue
146
+ if status == "SLOW_DOWN":
147
+ interval += SLOW_DOWN_INCREMENT
148
+ continue
149
+ if status == "ACCESS_DENIED":
150
+ raise ConfigError("Device authorization was denied.")
151
+ if status == "EXPIRED_TOKEN":
152
+ raise ConfigError("Device code expired before approval. Run: taktcli login")
153
+ if status == "ISSUED":
154
+ token = result.get("token")
155
+ if not token:
156
+ raise ConfigError("Server reported ISSUED but returned no token.")
157
+ return token
158
+ raise ConfigError(f"Unexpected device-token status: {status!r}")
159
+
160
+
161
+ def run_login(args, *, opener=None, sleep=None, out=None) -> int:
162
+ """``taktcli login`` — device-auth, then persist ``{token, url}``."""
163
+ out = out if out is not None else sys.stderr
164
+ url = _login_url(args)
165
+ token = device_login(
166
+ url,
167
+ client_name=getattr(args, "client_name", DEFAULT_CLIENT_NAME),
168
+ opener=opener,
169
+ sleep=sleep,
170
+ out=out,
171
+ )
172
+ path = save_credentials(token, url)
173
+ print(f"Logged in. Credentials saved to {path}", file=out)
174
+ out.flush()
175
+ return 0
176
+
177
+
178
+ def run_logout(args, *, out=None) -> int:
179
+ """``taktcli logout`` — remove the stored credential."""
180
+ out = out if out is not None else sys.stdout
181
+ path = credentials_path()
182
+ if clear_credentials(path):
183
+ print(f"Logged out. Removed {path}", file=out)
184
+ else:
185
+ print("Not logged in (no stored credential).", file=out)
186
+ out.flush()
187
+ return 0
188
+
189
+
190
+ def run_whoami(args, *, opener=None, out=None) -> int:
191
+ """``taktcli whoami`` — print identity + active credential source."""
192
+ out = out if out is not None else sys.stdout
193
+ opener = opener or urllib.request.urlopen
194
+ url, token = resolve_auth()
195
+ envelope = execute(ME_QUERY, {}, url=url, token=token, opener=opener)
196
+ me = (envelope.get("data") or {}).get("me") or {}
197
+ source = token_source() or "unknown"
198
+ claims = me.get("claims") or []
199
+ print(f"nickname: {me.get('nickname')}", file=out)
200
+ if me.get("displayName"):
201
+ print(f"display name: {me['displayName']}", file=out)
202
+ print(f"claims: {', '.join(claims) if claims else '(none)'}", file=out)
203
+ print(f"source: {source}", file=out)
204
+ print(f"endpoint: {url}", file=out)
205
+ out.flush()
206
+ return 0