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 +6 -0
- taktcli/__main__.py +53 -0
- taktcli/auth.py +104 -0
- taktcli/commands/__init__.py +68 -0
- taktcli/commands/_exec.py +85 -0
- taktcli/commands/_run.py +51 -0
- taktcli/commands/auth.py +206 -0
- taktcli/commands/file.py +183 -0
- taktcli/commands/gql.py +66 -0
- taktcli/commands/hotpath.py +150 -0
- taktcli/commands/label.py +89 -0
- taktcli/commands/notif.py +64 -0
- taktcli/commands/project.py +111 -0
- taktcli/commands/schema.py +56 -0
- taktcli/commands/task.py +418 -0
- taktcli/commands/watch.py +51 -0
- taktcli/commands/wiki.py +308 -0
- taktcli/commands/workspace.py +59 -0
- taktcli/errors.py +76 -0
- taktcli/introspection.py +253 -0
- taktcli/io.py +150 -0
- taktcli/transport.py +136 -0
- taktcli/watch.py +169 -0
- taktcli-0.1.0.dist-info/METADATA +117 -0
- taktcli-0.1.0.dist-info/RECORD +28 -0
- taktcli-0.1.0.dist-info/WHEEL +5 -0
- taktcli-0.1.0.dist-info/entry_points.txt +2 -0
- taktcli-0.1.0.dist-info/top_level.txt +1 -0
taktcli/__init__.py
ADDED
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
|
taktcli/commands/_run.py
ADDED
|
@@ -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
|
taktcli/commands/auth.py
ADDED
|
@@ -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
|