canvasctl 0.3.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.
- canvasctl/__init__.py +3 -0
- canvasctl/__main__.py +4 -0
- canvasctl/agent/__init__.py +39 -0
- canvasctl/agent/loop.py +245 -0
- canvasctl/agent/repl.py +76 -0
- canvasctl/cli.py +197 -0
- canvasctl/client.py +181 -0
- canvasctl/commands/__init__.py +1 -0
- canvasctl/commands/announcements.py +74 -0
- canvasctl/commands/changes.py +59 -0
- canvasctl/commands/config_cmd.py +42 -0
- canvasctl/commands/due.py +79 -0
- canvasctl/commands/ics.py +89 -0
- canvasctl/commands/list_cmd.py +142 -0
- canvasctl/commands/pull.py +114 -0
- canvasctl/commands/show.py +43 -0
- canvasctl/commands/status.py +133 -0
- canvasctl/commands/sync.py +141 -0
- canvasctl/commands/today.py +70 -0
- canvasctl/credentials.py +96 -0
- canvasctl/diff.py +29 -0
- canvasctl/formatting.py +66 -0
- canvasctl/mcp_server.py +137 -0
- canvasctl/ops/__init__.py +4 -0
- canvasctl/ops/catalog.py +659 -0
- canvasctl/ops/registry.py +97 -0
- canvasctl/records.py +45 -0
- canvasctl/setup_wizard.py +130 -0
- canvasctl/store.py +141 -0
- canvasctl/timeutil.py +117 -0
- canvasctl-0.3.0.dist-info/METADATA +254 -0
- canvasctl-0.3.0.dist-info/RECORD +36 -0
- canvasctl-0.3.0.dist-info/WHEEL +5 -0
- canvasctl-0.3.0.dist-info/entry_points.txt +2 -0
- canvasctl-0.3.0.dist-info/licenses/LICENSE +21 -0
- canvasctl-0.3.0.dist-info/top_level.txt +1 -0
canvasctl/__init__.py
ADDED
canvasctl/__main__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""canvasctl chat agent — a Claude-powered assistant over the ops registry.
|
|
2
|
+
|
|
3
|
+
Public surface: ``run_ask`` (single prompt) and ``run_chat`` (interactive REPL).
|
|
4
|
+
Both return an integer exit code and are safe to wire straight into the CLI.
|
|
5
|
+
``anthropic`` is imported lazily inside ``loop``/``repl`` so importing this
|
|
6
|
+
package never requires the optional dependency.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from ..formatting import Colors, colorize
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_ask(prompt: str, model: str | None = None) -> int:
|
|
16
|
+
"""Answer a single prompt (``canvasctl ask``). Returns an exit code."""
|
|
17
|
+
from .loop import AgentError, ask_once
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
text = ask_once(prompt, model=model)
|
|
21
|
+
except AgentError as exc:
|
|
22
|
+
print(colorize(str(exc), Colors.RED), file=sys.stderr)
|
|
23
|
+
return 1
|
|
24
|
+
except KeyboardInterrupt:
|
|
25
|
+
print(colorize("\nInterrupted.", Colors.YELLOW), file=sys.stderr)
|
|
26
|
+
return 130
|
|
27
|
+
if text:
|
|
28
|
+
print(text)
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_chat(model: str | None = None) -> int:
|
|
33
|
+
"""Start the interactive chat REPL (``canvasctl chat``). Returns an exit code."""
|
|
34
|
+
from .repl import run_chat as _run_chat
|
|
35
|
+
|
|
36
|
+
return _run_chat(model=model)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = ["run_ask", "run_chat"]
|
canvasctl/agent/loop.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Manual tool-use loop against the Anthropic Messages API.
|
|
2
|
+
|
|
3
|
+
The agent answers questions by calling the ops registry as tools. ``anthropic``
|
|
4
|
+
is an optional dependency, imported lazily so the core CLI works without it.
|
|
5
|
+
|
|
6
|
+
API facts baked in (do not "fix" from stale priors):
|
|
7
|
+
|
|
8
|
+
- default model ``claude-opus-4-8`` (override via ``CANVASCTL_MODEL`` or ``model=``)
|
|
9
|
+
- ``thinking={"type": "adaptive"}``; ``max_tokens=16000``
|
|
10
|
+
- NEVER pass ``temperature`` / ``top_p`` / ``top_k`` / ``budget_tokens`` (400s)
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..formatting import Colors, colorize
|
|
19
|
+
from ..ops import WRITE, all_ops, dispatch
|
|
20
|
+
|
|
21
|
+
MODEL_DEFAULT = "claude-opus-4-8"
|
|
22
|
+
MAX_TOKENS = 16000
|
|
23
|
+
MAX_ITERATIONS = 20
|
|
24
|
+
|
|
25
|
+
SYSTEM_PROMPT = (
|
|
26
|
+
"You are canvasctl, a terminal assistant for Canvas LMS. You answer "
|
|
27
|
+
"questions about the user's courses, assignments, quizzes, due dates, "
|
|
28
|
+
"announcements, and files using the provided tools, which read from a "
|
|
29
|
+
"local vault synced from Canvas.\n\n"
|
|
30
|
+
"Guidelines:\n"
|
|
31
|
+
"- Answer only from tool results. Never invent course data or due dates.\n"
|
|
32
|
+
"- Cite due dates and times concretely (e.g. 'due Fri Mar 14 at 11:59pm').\n"
|
|
33
|
+
"- If the vault looks empty or the data seems stale, suggest running "
|
|
34
|
+
"`canvasctl sync`.\n"
|
|
35
|
+
"- Be concise and terminal-appropriate: short sentences, no filler, no "
|
|
36
|
+
"markdown headers."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AgentError(RuntimeError):
|
|
41
|
+
"""Raised when the agent cannot run (missing dep or unconfigured key)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
# Registry / tool wiring
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
def _load_ops() -> list[Any]:
|
|
48
|
+
"""All registered ops. Importing the catalog populates the registry.
|
|
49
|
+
|
|
50
|
+
``catalog.py`` is written by a separate agent and may not exist yet; fall
|
|
51
|
+
back to whatever is already registered so this module is self-sufficient.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
import canvasctl.ops.catalog # noqa: F401
|
|
55
|
+
except ImportError:
|
|
56
|
+
pass
|
|
57
|
+
return all_ops()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_tools(ops: list[Any]) -> list[dict[str, Any]]:
|
|
61
|
+
"""Tool definitions in the shape the Messages API expects."""
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
"name": o.name,
|
|
65
|
+
"description": o.description,
|
|
66
|
+
"input_schema": o.input_schema,
|
|
67
|
+
}
|
|
68
|
+
for o in ops
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_model(model: str | None = None) -> str:
|
|
73
|
+
return model or os.environ.get("CANVASCTL_MODEL") or MODEL_DEFAULT
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --------------------------------------------------------------------------- #
|
|
77
|
+
# Anthropic client (lazy)
|
|
78
|
+
# --------------------------------------------------------------------------- #
|
|
79
|
+
def _make_client() -> Any:
|
|
80
|
+
"""Build a real Anthropic client, or raise a friendly ``AgentError``."""
|
|
81
|
+
try:
|
|
82
|
+
import anthropic # noqa: F401
|
|
83
|
+
except ImportError as exc: # pragma: no cover - exercised via message only
|
|
84
|
+
raise AgentError(
|
|
85
|
+
"Chat features need the 'anthropic' package.\n"
|
|
86
|
+
" Install it with: pip install canvasctl[agent]"
|
|
87
|
+
) from exc
|
|
88
|
+
|
|
89
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
90
|
+
raise AgentError(
|
|
91
|
+
"No Anthropic API key found.\n"
|
|
92
|
+
" Run `canvasctl setup` to add one, or set ANTHROPIC_API_KEY."
|
|
93
|
+
)
|
|
94
|
+
return anthropic.Anthropic()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --------------------------------------------------------------------------- #
|
|
98
|
+
# Response helpers
|
|
99
|
+
# --------------------------------------------------------------------------- #
|
|
100
|
+
def _collect_text(response: Any) -> str:
|
|
101
|
+
chunks: list[str] = []
|
|
102
|
+
for block in getattr(response, "content", None) or []:
|
|
103
|
+
if getattr(block, "type", None) == "text":
|
|
104
|
+
chunks.append(block.text)
|
|
105
|
+
return "\n".join(chunks).strip()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_error_payload(text: str) -> bool:
|
|
109
|
+
"""dispatch() returns JSON; an error is ``{"error": ...}``."""
|
|
110
|
+
try:
|
|
111
|
+
parsed = json.loads(text)
|
|
112
|
+
except (ValueError, TypeError):
|
|
113
|
+
return False
|
|
114
|
+
return isinstance(parsed, dict) and "error" in parsed
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _print_activity(name: str, args: dict[str, Any]) -> None:
|
|
118
|
+
parts = " ".join(f"{k}={v}" for k, v in (args or {}).items())
|
|
119
|
+
line = f"→ {name} {parts}".rstrip()
|
|
120
|
+
print(colorize(line, Colors.DIM))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _confirm_write(name: str, args: dict[str, Any]) -> bool:
|
|
124
|
+
"""Interactive y/N gate before executing a WRITE op."""
|
|
125
|
+
print(colorize(f"About to run write operation: {name}", Colors.YELLOW + Colors.BOLD))
|
|
126
|
+
if args:
|
|
127
|
+
print(colorize(f" args: {json.dumps(args, default=str)}", Colors.YELLOW))
|
|
128
|
+
try:
|
|
129
|
+
answer = input(colorize("Proceed? [y/N] ", Colors.BOLD))
|
|
130
|
+
except EOFError:
|
|
131
|
+
answer = ""
|
|
132
|
+
return answer.strip().lower() in ("y", "yes")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _execute_tools(response: Any, ops: list[Any]) -> list[dict[str, Any]]:
|
|
136
|
+
"""Run every tool_use block; return all tool_result blocks (one batch)."""
|
|
137
|
+
by_name = {o.name: o for o in ops}
|
|
138
|
+
results: list[dict[str, Any]] = []
|
|
139
|
+
for block in getattr(response, "content", None) or []:
|
|
140
|
+
if getattr(block, "type", None) != "tool_use":
|
|
141
|
+
continue
|
|
142
|
+
name = block.name
|
|
143
|
+
args = block.input or {}
|
|
144
|
+
_print_activity(name, args)
|
|
145
|
+
|
|
146
|
+
op = by_name.get(name)
|
|
147
|
+
if op is not None and op.kind == WRITE:
|
|
148
|
+
if not _confirm_write(name, args):
|
|
149
|
+
results.append(
|
|
150
|
+
{
|
|
151
|
+
"type": "tool_result",
|
|
152
|
+
"tool_use_id": block.id,
|
|
153
|
+
"content": "The user declined to run this write operation.",
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
text = dispatch(name, args)
|
|
159
|
+
results.append(
|
|
160
|
+
{
|
|
161
|
+
"type": "tool_result",
|
|
162
|
+
"tool_use_id": block.id,
|
|
163
|
+
"content": text,
|
|
164
|
+
"is_error": _is_error_payload(text),
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
return results
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------- #
|
|
171
|
+
# Core loop
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
def _run_loop(
|
|
174
|
+
client: Any,
|
|
175
|
+
messages: list[dict[str, Any]],
|
|
176
|
+
*,
|
|
177
|
+
model: str,
|
|
178
|
+
ops: list[Any],
|
|
179
|
+
tools: list[dict[str, Any]],
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Drive the tool-use loop, mutating ``messages`` in place.
|
|
182
|
+
|
|
183
|
+
Always leaves ``messages`` ending on the assistant turn so multi-turn chat
|
|
184
|
+
can simply append the next user message. Returns the final text.
|
|
185
|
+
"""
|
|
186
|
+
for _ in range(MAX_ITERATIONS):
|
|
187
|
+
kwargs: dict[str, Any] = {
|
|
188
|
+
"model": model,
|
|
189
|
+
"max_tokens": MAX_TOKENS,
|
|
190
|
+
"thinking": {"type": "adaptive"},
|
|
191
|
+
"system": SYSTEM_PROMPT,
|
|
192
|
+
"messages": messages,
|
|
193
|
+
}
|
|
194
|
+
if tools:
|
|
195
|
+
kwargs["tools"] = tools
|
|
196
|
+
response = client.messages.create(**kwargs)
|
|
197
|
+
stop = getattr(response, "stop_reason", None)
|
|
198
|
+
|
|
199
|
+
if stop == "pause_turn":
|
|
200
|
+
# Server paused a long turn; echo it back and resend to continue.
|
|
201
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
if stop == "tool_use":
|
|
205
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
206
|
+
messages.append(
|
|
207
|
+
{"role": "user", "content": _execute_tools(response, ops)}
|
|
208
|
+
)
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# Terminal turn: end_turn / max_tokens / refusal / stop_sequence / ...
|
|
212
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
213
|
+
text = _collect_text(response)
|
|
214
|
+
if stop == "refusal":
|
|
215
|
+
print(colorize("Claude declined to respond to that request.", Colors.YELLOW))
|
|
216
|
+
elif stop == "max_tokens":
|
|
217
|
+
print(colorize("(output was truncated — hit the max token limit)", Colors.YELLOW))
|
|
218
|
+
return text
|
|
219
|
+
|
|
220
|
+
print(colorize("(stopped after too many tool-use rounds)", Colors.YELLOW))
|
|
221
|
+
return ""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --------------------------------------------------------------------------- #
|
|
225
|
+
# Public entry points
|
|
226
|
+
# --------------------------------------------------------------------------- #
|
|
227
|
+
def ask_once(
|
|
228
|
+
prompt: str,
|
|
229
|
+
*,
|
|
230
|
+
client: Any = None,
|
|
231
|
+
model: str | None = None,
|
|
232
|
+
ops: list[Any] | None = None,
|
|
233
|
+
) -> str:
|
|
234
|
+
"""Answer a single prompt and return Claude's final text.
|
|
235
|
+
|
|
236
|
+
``client`` and ``ops`` are injectable for tests; production callers leave
|
|
237
|
+
them unset so a real client and the full registry are used.
|
|
238
|
+
"""
|
|
239
|
+
if ops is None:
|
|
240
|
+
ops = _load_ops()
|
|
241
|
+
tools = _build_tools(ops)
|
|
242
|
+
if client is None:
|
|
243
|
+
client = _make_client()
|
|
244
|
+
messages: list[dict[str, Any]] = [{"role": "user", "content": prompt}]
|
|
245
|
+
return _run_loop(client, messages, model=_resolve_model(model), ops=ops, tools=tools)
|
canvasctl/agent/repl.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Interactive chat REPL for ``canvasctl chat``.
|
|
2
|
+
|
|
3
|
+
Preserves conversation history across turns and reuses the tool-use loop in
|
|
4
|
+
``loop.py``. Supports ``/help``, ``/clear``, ``/exit``; Ctrl-C and Ctrl-D exit
|
|
5
|
+
cleanly.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..formatting import Colors, colorize
|
|
12
|
+
from . import loop
|
|
13
|
+
|
|
14
|
+
_BANNER = (
|
|
15
|
+
"canvasctl chat — ask about your courses, assignments, and due dates.\n"
|
|
16
|
+
"Type /help for commands, /exit to quit."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_HELP = (
|
|
20
|
+
"Commands:\n"
|
|
21
|
+
" /help Show this help\n"
|
|
22
|
+
" /clear Forget the current conversation history\n"
|
|
23
|
+
" /exit Quit (Ctrl-D or Ctrl-C also work)"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_chat(model: str | None = None) -> int:
|
|
28
|
+
"""Run the chat REPL. Returns an exit code."""
|
|
29
|
+
ops = loop._load_ops()
|
|
30
|
+
tools = loop._build_tools(ops)
|
|
31
|
+
try:
|
|
32
|
+
client = loop._make_client()
|
|
33
|
+
except loop.AgentError as exc:
|
|
34
|
+
print(colorize(str(exc), Colors.RED))
|
|
35
|
+
return 1
|
|
36
|
+
|
|
37
|
+
resolved_model = loop._resolve_model(model)
|
|
38
|
+
print(colorize(_BANNER, Colors.CYAN))
|
|
39
|
+
messages: list[dict[str, Any]] = []
|
|
40
|
+
|
|
41
|
+
while True:
|
|
42
|
+
try:
|
|
43
|
+
line = input(colorize("\ncanvasctl> ", Colors.GREEN + Colors.BOLD))
|
|
44
|
+
except (EOFError, KeyboardInterrupt):
|
|
45
|
+
print()
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
cmd = line.strip()
|
|
49
|
+
if not cmd:
|
|
50
|
+
continue
|
|
51
|
+
if cmd in ("/exit", "/quit"):
|
|
52
|
+
break
|
|
53
|
+
if cmd == "/help":
|
|
54
|
+
print(_HELP)
|
|
55
|
+
continue
|
|
56
|
+
if cmd == "/clear":
|
|
57
|
+
messages.clear()
|
|
58
|
+
print(colorize("(conversation history cleared)", Colors.GRAY))
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
messages.append({"role": "user", "content": cmd})
|
|
62
|
+
try:
|
|
63
|
+
text = loop._run_loop(
|
|
64
|
+
client, messages, model=resolved_model, ops=ops, tools=tools
|
|
65
|
+
)
|
|
66
|
+
except KeyboardInterrupt:
|
|
67
|
+
print(colorize("\n(interrupted)", Colors.YELLOW))
|
|
68
|
+
break
|
|
69
|
+
except loop.AgentError as exc:
|
|
70
|
+
print(colorize(str(exc), Colors.RED))
|
|
71
|
+
continue
|
|
72
|
+
if text:
|
|
73
|
+
print("\n" + text)
|
|
74
|
+
|
|
75
|
+
print(colorize("bye.", Colors.GRAY))
|
|
76
|
+
return 0
|
canvasctl/cli.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""CLI entrypoint for canvasctl."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__, agent, credentials, setup_wizard
|
|
11
|
+
from .commands import (
|
|
12
|
+
announcements as cmd_announcements,
|
|
13
|
+
)
|
|
14
|
+
from .commands import (
|
|
15
|
+
changes as cmd_changes,
|
|
16
|
+
)
|
|
17
|
+
from .commands import (
|
|
18
|
+
config_cmd,
|
|
19
|
+
list_cmd,
|
|
20
|
+
)
|
|
21
|
+
from .commands import (
|
|
22
|
+
due as cmd_due,
|
|
23
|
+
)
|
|
24
|
+
from .commands import (
|
|
25
|
+
ics as cmd_ics,
|
|
26
|
+
)
|
|
27
|
+
from .commands import (
|
|
28
|
+
pull as cmd_pull,
|
|
29
|
+
)
|
|
30
|
+
from .commands import (
|
|
31
|
+
show as cmd_show,
|
|
32
|
+
)
|
|
33
|
+
from .commands import (
|
|
34
|
+
status as cmd_status,
|
|
35
|
+
)
|
|
36
|
+
from .commands import (
|
|
37
|
+
sync as cmd_sync,
|
|
38
|
+
)
|
|
39
|
+
from .commands import (
|
|
40
|
+
today as cmd_today,
|
|
41
|
+
)
|
|
42
|
+
from .formatting import Colors, colorize
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
p = argparse.ArgumentParser(
|
|
47
|
+
prog="canvasctl",
|
|
48
|
+
description="Canvas in your terminal: local vault, chat agent, and MCP server for Canvas LMS.",
|
|
49
|
+
)
|
|
50
|
+
p.add_argument("--version", action="version", version=f"canvasctl {__version__}")
|
|
51
|
+
p.add_argument("--vault", default=None, help="Vault directory (default: ~/canvas-vault)")
|
|
52
|
+
p.add_argument("--json", action="store_true", help="Emit JSON output where supported")
|
|
53
|
+
p.add_argument("-v", "--verbose", action="count", default=0, help="Increase log verbosity")
|
|
54
|
+
|
|
55
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
56
|
+
|
|
57
|
+
pcfg = sub.add_parser("config", help="Show or modify configuration")
|
|
58
|
+
pcfg.add_argument("--get", action="store_true")
|
|
59
|
+
pcfg.add_argument("--set-alias", dest="set_alias", help="COURSE_ID=ALIAS")
|
|
60
|
+
pcfg.add_argument("--unset-alias", dest="unset_alias", help="COURSE_ID")
|
|
61
|
+
|
|
62
|
+
psync = sub.add_parser("sync", help="Pull metadata from Canvas")
|
|
63
|
+
psync.add_argument("--course", default=None, help="Sync a single course only")
|
|
64
|
+
psync.add_argument("--workers", type=int, default=8, help="Concurrent course fetches")
|
|
65
|
+
psync.add_argument("--no-modules", action="store_true", help="Skip slow module-items fetch")
|
|
66
|
+
psync.add_argument("--no-files", action="store_true", help="Skip files fetch")
|
|
67
|
+
|
|
68
|
+
ptoday = sub.add_parser("today", help="Show items due today")
|
|
69
|
+
ptoday.add_argument("--course", default=None)
|
|
70
|
+
ptoday.add_argument("--type", choices=["assignment", "quiz", "all"], default="all")
|
|
71
|
+
|
|
72
|
+
pdue = sub.add_parser("due", help="Show items due in a future window")
|
|
73
|
+
pdue.add_argument("--course", default=None)
|
|
74
|
+
pdue.add_argument("--range", default="7d")
|
|
75
|
+
pdue.add_argument("--type", choices=["assignment", "quiz", "all"], default="all")
|
|
76
|
+
|
|
77
|
+
pann = sub.add_parser("announcements", aliases=["ann"], help="Show recent announcements")
|
|
78
|
+
pann.add_argument("--course", default=None)
|
|
79
|
+
pann.add_argument("--since", default="7d")
|
|
80
|
+
pann.add_argument("--verbose", "-V", action="store_true", help="Show message previews")
|
|
81
|
+
|
|
82
|
+
sub.add_parser("status", aliases=["dashboard", "dash"], help="Overview dashboard")
|
|
83
|
+
|
|
84
|
+
pchg = sub.add_parser("changes", help="Show recent change-log entries")
|
|
85
|
+
pchg.add_argument("--course", default=None)
|
|
86
|
+
pchg.add_argument("--since", default="24h")
|
|
87
|
+
pchg.add_argument("--type", default="all")
|
|
88
|
+
|
|
89
|
+
plist = sub.add_parser("list", help="List courses or per-course resources")
|
|
90
|
+
plist.add_argument("what", choices=list_cmd.LIST_KINDS)
|
|
91
|
+
plist.add_argument("--course", default=None)
|
|
92
|
+
plist.add_argument("--verbose", "-V", action="store_true")
|
|
93
|
+
|
|
94
|
+
pshow = sub.add_parser("show", help="Show a single item as JSON")
|
|
95
|
+
pshow.add_argument("--course", required=True)
|
|
96
|
+
pshow.add_argument("--assignment", default=None)
|
|
97
|
+
pshow.add_argument("--quiz", default=None)
|
|
98
|
+
pshow.add_argument("--announcement", default=None)
|
|
99
|
+
pshow.add_argument("--file", default=None)
|
|
100
|
+
pshow.add_argument("--module", default=None)
|
|
101
|
+
|
|
102
|
+
ppull = sub.add_parser("pull", help="Download course files into the vault")
|
|
103
|
+
ppull.add_argument("--course", default=None)
|
|
104
|
+
ppull.add_argument("--workers", type=int, default=4)
|
|
105
|
+
ppull.add_argument("--dry-run", action="store_true")
|
|
106
|
+
|
|
107
|
+
pics = sub.add_parser("ics", help="Export due dates as an .ics calendar file")
|
|
108
|
+
pics.add_argument("--course", default=None)
|
|
109
|
+
pics.add_argument("--range", default="60d")
|
|
110
|
+
pics.add_argument("--output", "-o", default=None)
|
|
111
|
+
|
|
112
|
+
sub.add_parser("setup", help="Interactive first-time setup (Canvas URL, token, API key)")
|
|
113
|
+
|
|
114
|
+
pask = sub.add_parser("ask", help="Ask Claude a one-off question about your courses")
|
|
115
|
+
pask.add_argument("prompt", nargs="+", help="Your question (quotes optional)")
|
|
116
|
+
pask.add_argument("--model", default=None, help="Override the model (default: claude-opus-4-8)")
|
|
117
|
+
|
|
118
|
+
pchat = sub.add_parser("chat", help="Start an interactive chat session with your courses")
|
|
119
|
+
pchat.add_argument("--model", default=None, help="Override the model (default: claude-opus-4-8)")
|
|
120
|
+
|
|
121
|
+
sub.add_parser("mcp", help="Run the MCP stdio server (for Claude Code / Claude Desktop)")
|
|
122
|
+
|
|
123
|
+
return p
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_DISPATCH = {
|
|
127
|
+
"config": config_cmd.run,
|
|
128
|
+
"sync": cmd_sync.run,
|
|
129
|
+
"today": cmd_today.run,
|
|
130
|
+
"due": cmd_due.run,
|
|
131
|
+
"announcements": cmd_announcements.run,
|
|
132
|
+
"ann": cmd_announcements.run,
|
|
133
|
+
"status": cmd_status.run,
|
|
134
|
+
"dashboard": cmd_status.run,
|
|
135
|
+
"dash": cmd_status.run,
|
|
136
|
+
"changes": cmd_changes.run,
|
|
137
|
+
"list": list_cmd.run,
|
|
138
|
+
"show": cmd_show.run,
|
|
139
|
+
"pull": cmd_pull.run,
|
|
140
|
+
"ics": cmd_ics.run,
|
|
141
|
+
"setup": setup_wizard.run,
|
|
142
|
+
"ask": lambda args: agent.run_ask(" ".join(args.prompt), args.model),
|
|
143
|
+
"chat": lambda args: agent.run_chat(args.model),
|
|
144
|
+
"mcp": lambda args: _run_mcp(args),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _bridge_vault_env(args: argparse.Namespace) -> None:
|
|
149
|
+
# The ops catalog reads the vault from CANVASCTL_VAULT; bridge the global flag into it.
|
|
150
|
+
if getattr(args, "vault", None):
|
|
151
|
+
os.environ["CANVASCTL_VAULT"] = os.path.expanduser(args.vault)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _run_mcp(args: argparse.Namespace) -> int:
|
|
155
|
+
from .mcp_server import serve # lazy: keeps `mcp` an optional dependency
|
|
156
|
+
|
|
157
|
+
serve() # blocks, serving over stdio until the client disconnects
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _configure_logging(verbosity: int) -> None:
|
|
162
|
+
level = logging.WARNING
|
|
163
|
+
if verbosity == 1:
|
|
164
|
+
level = logging.INFO
|
|
165
|
+
elif verbosity >= 2:
|
|
166
|
+
level = logging.DEBUG
|
|
167
|
+
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main(argv: list[str] | None = None) -> int:
|
|
171
|
+
parser = build_parser()
|
|
172
|
+
args = parser.parse_args(argv)
|
|
173
|
+
_configure_logging(args.verbose)
|
|
174
|
+
|
|
175
|
+
credentials.apply_to_env() # bridge ~/.config/canvasctl/config.json into the env (env wins)
|
|
176
|
+
_bridge_vault_env(args) # ops catalog (ask/chat/mcp) reads the vault from CANVASCTL_VAULT
|
|
177
|
+
|
|
178
|
+
handler = _DISPATCH.get(args.cmd)
|
|
179
|
+
if handler is None:
|
|
180
|
+
parser.error(f"Unknown command: {args.cmd}")
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
return handler(args) or 0
|
|
184
|
+
except SystemExit:
|
|
185
|
+
raise
|
|
186
|
+
except KeyboardInterrupt:
|
|
187
|
+
print(colorize("\nInterrupted.", Colors.YELLOW), file=sys.stderr)
|
|
188
|
+
return 130
|
|
189
|
+
except Exception as e:
|
|
190
|
+
if args.verbose >= 2:
|
|
191
|
+
raise
|
|
192
|
+
print(colorize(f"Error: {e}", Colors.RED), file=sys.stderr)
|
|
193
|
+
return 1
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
sys.exit(main())
|