runspec-windows 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 (29) hide show
  1. runspec_windows-0.1.0/.gitignore +58 -0
  2. runspec_windows-0.1.0/CHANGELOG.md +35 -0
  3. runspec_windows-0.1.0/PKG-INFO +15 -0
  4. runspec_windows-0.1.0/README.md +119 -0
  5. runspec_windows-0.1.0/pyproject.toml +93 -0
  6. runspec_windows-0.1.0/runspec_windows/__init__.py +14 -0
  7. runspec_windows-0.1.0/runspec_windows/_platform.py +65 -0
  8. runspec_windows-0.1.0/runspec_windows/eventlog.py +87 -0
  9. runspec_windows-0.1.0/runspec_windows/graph/__init__.py +24 -0
  10. runspec_windows-0.1.0/runspec_windows/graph/_emit.py +20 -0
  11. runspec_windows-0.1.0/runspec_windows/graph/account.py +48 -0
  12. runspec_windows-0.1.0/runspec_windows/graph/auth.py +127 -0
  13. runspec_windows-0.1.0/runspec_windows/graph/calendar.py +77 -0
  14. runspec_windows-0.1.0/runspec_windows/graph/client.py +43 -0
  15. runspec_windows-0.1.0/runspec_windows/graph/files.py +51 -0
  16. runspec_windows-0.1.0/runspec_windows/graph/outlook.py +79 -0
  17. runspec_windows-0.1.0/runspec_windows/graph/teams.py +62 -0
  18. runspec_windows-0.1.0/runspec_windows/network.py +160 -0
  19. runspec_windows-0.1.0/runspec_windows/processes.py +95 -0
  20. runspec_windows-0.1.0/runspec_windows/runspec.toml +425 -0
  21. runspec_windows-0.1.0/runspec_windows/services.py +97 -0
  22. runspec_windows-0.1.0/runspec_windows/sessions.py +83 -0
  23. runspec_windows-0.1.0/runspec_windows/software.py +76 -0
  24. runspec_windows-0.1.0/runspec_windows/system_info.py +138 -0
  25. runspec_windows-0.1.0/runspec_windows/tasks.py +59 -0
  26. runspec_windows-0.1.0/tests/__init__.py +0 -0
  27. runspec_windows-0.1.0/tests/test_graph.py +236 -0
  28. runspec_windows-0.1.0/tests/test_parsers.py +119 -0
  29. runspec_windows-0.1.0/tests/test_platform_guard.py +37 -0
@@ -0,0 +1,58 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ .env
16
+ pip-wheel-metadata/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ htmlcov/
21
+ .coverage
22
+ coverage.xml
23
+ *.cover
24
+
25
+ # Node
26
+ node_modules/
27
+ dist/
28
+ *.js.map
29
+ .npm
30
+
31
+ # Go
32
+ *.exe
33
+ *.test
34
+ *.out
35
+ vendor/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.iml
41
+ *.iws
42
+ *.ipr
43
+ .DS_Store
44
+ Thumbs.db
45
+
46
+ # Docs
47
+ site/
48
+
49
+ # Misc
50
+ *.log
51
+ *.tmp
52
+
53
+ # External reference repos (cloned locally, not committed)
54
+ chainlit-docs/
55
+ .chainlit/
56
+
57
+ # Claude Code local config (machine-specific)
58
+ .claude/launch.json
@@ -0,0 +1,35 @@
1
+ # runspec-windows Changelog
2
+
3
+ ## [0.1.0] — 2026-06-03
4
+
5
+ Initial release.
6
+
7
+ 35 runnables covering Windows system administration and Microsoft 365:
8
+
9
+ **System monitoring** — `system-info`, `disk-usage`, `check-memory`, `uptime`
10
+
11
+ **Processes** — `list-processes`, `process-info`, `kill-process`
12
+
13
+ **Services** — `list-services`, `check-service`, `restart-service`, `stop-service`
14
+
15
+ **Network** — `ping-host`, `check-port`, `show-connections`, `list-adapters`, `flush-dns`
16
+
17
+ **Event log** — `query-eventlog`, `recent-errors`
18
+
19
+ **Scheduled tasks** — `list-scheduled-tasks`
20
+
21
+ **Installed software** — `installed-software`
22
+
23
+ **Sessions** — `who`, `current-user`
24
+
25
+ **Microsoft Graph (Outlook + Teams + Calendar + OneDrive)** — `graph-login`, `graph-whoami`, `outlook-unread`, `outlook-search`, `outlook-folders`, `teams-list-chats`, `teams-read-chat`, `calendar-upcoming`, `calendar-today`, `next-meeting`, `onedrive-recent`, `onedrive-search`, `onedrive-list`
26
+
27
+ Read-only runnables are `autonomy = "autonomous"`; state-changing ones
28
+ (`kill-process`, `restart-service`, `stop-service`, `flush-dns`, `graph-login`)
29
+ are `autonomy = "confirm"`. Windows-only runnables return a structured JSON
30
+ error (not a traceback) when run on a non-Windows host.
31
+
32
+ Microsoft Graph runnables use delegated **device-code** auth via MSAL — no
33
+ client secret. The optional `msal`/`httpx` dependencies live behind the
34
+ `[graph]` extra. The package also exports `graph_get()` and `get_token()` as a
35
+ public Python API for building further Graph-backed wrapper runnables.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-windows
3
+ Version: 0.1.0
4
+ Summary: Windows system admin + Microsoft 365 (Outlook, Teams) runnables for runspec
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: runspec>=0.23.0
7
+ Provides-Extra: dev
8
+ Requires-Dist: httpx>=0.27; extra == 'dev'
9
+ Requires-Dist: msal>=1.28; extra == 'dev'
10
+ Requires-Dist: mypy; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Requires-Dist: ruff; extra == 'dev'
13
+ Provides-Extra: graph
14
+ Requires-Dist: httpx>=0.27; extra == 'graph'
15
+ Requires-Dist: msal>=1.28; extra == 'graph'
@@ -0,0 +1,119 @@
1
+ # runspec-windows
2
+
3
+ Windows system-administration and Microsoft 365 (Outlook + Teams) runnables for
4
+ [runspec](https://pypi.org/project/runspec/). The Windows counterpart to
5
+ [`runspec-linux`](../runspec-linux): `pip install` it into a venv and the
6
+ runnables are discoverable by `runspec local`, `runspec serve` (MCP), and
7
+ runspec-console.
8
+
9
+ All runnables emit JSON on stdout. Read-only runnables are `autonomy =
10
+ "autonomous"`; state-changing ones (`kill-process`, `restart-service`,
11
+ `stop-service`, `flush-dns`, `graph-login`) are `autonomy = "confirm"`.
12
+
13
+ ## Install
14
+
15
+ ```
16
+ pip install runspec-windows # system utilities only
17
+ pip install "runspec-windows[graph]" # + Microsoft 365 (Outlook/Teams) runnables
18
+ ```
19
+
20
+ ## Runnables
21
+
22
+ | Group | Runnables |
23
+ |---|---|
24
+ | System | `system-info`, `disk-usage`, `check-memory`, `uptime` |
25
+ | Processes | `list-processes`, `process-info`, `kill-process` |
26
+ | Services | `list-services`, `check-service`, `restart-service`, `stop-service` |
27
+ | Network | `ping-host`, `check-port`, `show-connections`, `list-adapters`, `flush-dns` |
28
+ | Event log | `query-eventlog`, `recent-errors` |
29
+ | Scheduled tasks | `list-scheduled-tasks` |
30
+ | Installed software | `installed-software` |
31
+ | Sessions | `who`, `current-user` |
32
+ | Microsoft 365 | `graph-login`, `graph-whoami`, `outlook-unread`, `outlook-search`, `outlook-folders`, `teams-list-chats`, `teams-read-chat`, `calendar-upcoming`, `calendar-today`, `next-meeting`, `onedrive-recent`, `onedrive-search`, `onedrive-list` |
33
+
34
+ The system runnables are Windows-only; run one on macOS/Linux and it returns a
35
+ clean JSON error rather than a traceback. The Microsoft Graph runnables are pure
36
+ HTTP and run anywhere — they only need a token.
37
+
38
+ ```
39
+ system-info
40
+ list-processes --filter chrome --limit 10
41
+ query-eventlog --log System --level error --count 20
42
+ installed-software --filter "Microsoft"
43
+ ```
44
+
45
+ ## Microsoft 365 (Outlook + Teams) — one-time setup
46
+
47
+ The Graph runnables authenticate as the signed-in user via the OAuth
48
+ **device-code** flow (no client secret, no admin app password). You need a
49
+ public-client **app registration** in your Entra ID (Azure AD) tenant:
50
+
51
+ 1. **Entra admin center → App registrations → New registration.** Give it a
52
+ name; under *Supported account types* pick whatever matches your tenant.
53
+ 2. **Authentication → Advanced settings → Allow public client flows → Yes.**
54
+ (Device-code flow requires this.)
55
+ 3. **API permissions → Add → Microsoft Graph → Delegated**, add
56
+ `User.Read`, `Mail.Read`, `Chat.Read`, `Calendars.Read`, `Files.Read.All`,
57
+ then *Grant admin consent* if your tenant requires it.
58
+ 4. Copy the **Application (client) ID**.
59
+
60
+ Then point the package at it (the client id is **not** a secret):
61
+
62
+ ```
63
+ set RUNSPEC_GRAPH_CLIENT_ID=<application-client-id>
64
+ set RUNSPEC_GRAPH_TENANT=<tenant-id-or-domain> # optional; default "organizations"
65
+ ```
66
+
67
+ …or create `%APPDATA%\runspec-windows\graph.toml`:
68
+
69
+ ```toml
70
+ client_id = "00000000-0000-0000-0000-000000000000"
71
+ tenant = "contoso.onmicrosoft.com"
72
+ ```
73
+
74
+ Sign in once (prints a code + URL to visit; the token is cached under
75
+ `%APPDATA%\runspec-windows\msal_cache.bin`):
76
+
77
+ ```
78
+ graph-login
79
+ graph-whoami
80
+ outlook-unread --count 10
81
+ outlook-search --query "invoice"
82
+ teams-list-chats
83
+ teams-read-chat --chat-id 19:abc...@thread.v2
84
+ calendar-upcoming --days 7
85
+ calendar-today
86
+ next-meeting
87
+ onedrive-recent
88
+ onedrive-search --query "budget"
89
+ onedrive-list --path "/Documents"
90
+ ```
91
+
92
+ Tokens are cached and refreshed silently; rerun `graph-login` if the cache is
93
+ cleared or consent changes.
94
+
95
+ ## Public Python API
96
+
97
+ Mirroring `runspec-linux`'s `nc_send`, this package exports the authenticated
98
+ Graph client so you can build your own wrapper runnables:
99
+
100
+ ```python
101
+ from runspec_windows import graph_get, get_token
102
+
103
+ me = graph_get("/me")
104
+ events = graph_get("/me/events", params={"$top": 5})
105
+ ```
106
+
107
+ ## Development
108
+
109
+ ```
110
+ python -m venv .venv && . .venv/bin/activate # or .venv\Scripts\activate
111
+ pip install -e ".[dev,graph]"
112
+ ruff check . && ruff format --check .
113
+ pytest
114
+ ```
115
+
116
+ The OS-specific work sits behind thin helpers in `_platform.py`; the pure output
117
+ parsers (`tasklist`/`netstat`/`ipconfig`/`schtasks`/`query user`) and the Graph
118
+ formatters are unit-tested on any platform. Real end-to-end runs of the
119
+ Windows-only runnables happen on a Windows host / the `windows-latest` CI job.
@@ -0,0 +1,93 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-windows"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ description = "Windows system admin + Microsoft 365 (Outlook, Teams) runnables for runspec"
10
+ dependencies = [
11
+ "runspec>=0.23.0",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ # Microsoft Graph runnables (Outlook + Teams). Pure HTTP — no pywin32 needed.
16
+ graph = [
17
+ "msal>=1.28",
18
+ "httpx>=0.27",
19
+ ]
20
+ dev = [
21
+ "ruff",
22
+ "mypy",
23
+ "pytest>=8.0",
24
+ "msal>=1.28",
25
+ "httpx>=0.27",
26
+ ]
27
+
28
+ [project.scripts]
29
+ # System monitoring
30
+ system-info = "runspec_windows.system_info:main_system_info"
31
+ disk-usage = "runspec_windows.system_info:main_disk_usage"
32
+ check-memory = "runspec_windows.system_info:main_check_memory"
33
+ uptime = "runspec_windows.system_info:main_uptime"
34
+ # Processes
35
+ list-processes = "runspec_windows.processes:main_list_processes"
36
+ process-info = "runspec_windows.processes:main_process_info"
37
+ kill-process = "runspec_windows.processes:main_kill_process"
38
+ # Services
39
+ list-services = "runspec_windows.services:main_list_services"
40
+ check-service = "runspec_windows.services:main_check_service"
41
+ restart-service = "runspec_windows.services:main_restart_service"
42
+ stop-service = "runspec_windows.services:main_stop_service"
43
+ # Network
44
+ ping-host = "runspec_windows.network:main_ping_host"
45
+ check-port = "runspec_windows.network:main_check_port"
46
+ show-connections = "runspec_windows.network:main_show_connections"
47
+ list-adapters = "runspec_windows.network:main_list_adapters"
48
+ flush-dns = "runspec_windows.network:main_flush_dns"
49
+ # Event log
50
+ query-eventlog = "runspec_windows.eventlog:main_query_eventlog"
51
+ recent-errors = "runspec_windows.eventlog:main_recent_errors"
52
+ # Scheduled tasks
53
+ list-scheduled-tasks = "runspec_windows.tasks:main_list_scheduled_tasks"
54
+ # Installed software
55
+ installed-software = "runspec_windows.software:main_installed_software"
56
+ # Sessions
57
+ who = "runspec_windows.sessions:main_who"
58
+ current-user = "runspec_windows.sessions:main_current_user"
59
+ # Microsoft Graph — account
60
+ graph-login = "runspec_windows.graph.account:main_graph_login"
61
+ graph-whoami = "runspec_windows.graph.account:main_graph_whoami"
62
+ # Microsoft Graph — Outlook
63
+ outlook-unread = "runspec_windows.graph.outlook:main_outlook_unread"
64
+ outlook-search = "runspec_windows.graph.outlook:main_outlook_search"
65
+ outlook-folders = "runspec_windows.graph.outlook:main_outlook_folders"
66
+ # Microsoft Graph — Calendar
67
+ calendar-upcoming = "runspec_windows.graph.calendar:main_calendar_upcoming"
68
+ calendar-today = "runspec_windows.graph.calendar:main_calendar_today"
69
+ next-meeting = "runspec_windows.graph.calendar:main_next_meeting"
70
+ # Microsoft Graph — OneDrive / Files
71
+ onedrive-recent = "runspec_windows.graph.files:main_onedrive_recent"
72
+ onedrive-search = "runspec_windows.graph.files:main_onedrive_search"
73
+ onedrive-list = "runspec_windows.graph.files:main_onedrive_list"
74
+ # Microsoft Graph — Teams
75
+ teams-list-chats = "runspec_windows.graph.teams:main_teams_list_chats"
76
+ teams-read-chat = "runspec_windows.graph.teams:main_teams_read_chat"
77
+
78
+ [tool.pytest.ini_options]
79
+ testpaths = ["tests"]
80
+
81
+ [tool.mypy]
82
+ python_version = "3.10"
83
+
84
+ [[tool.mypy.overrides]]
85
+ module = ["msal.*"]
86
+ ignore_missing_imports = true
87
+
88
+ [tool.ruff]
89
+ line-length = 200
90
+ target-version = "py310"
91
+
92
+ [tool.ruff.lint]
93
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,14 @@
1
+ """runspec-windows — Windows system admin + Microsoft 365 runnables for runspec.
2
+
3
+ Public API (parallels runspec-linux's ``nc_send``): ``graph_get`` and
4
+ ``get_token`` let other wrapper runnables reuse the authenticated Microsoft
5
+ Graph client without re-implementing the device-code flow.
6
+
7
+ Importing this package never requires the optional ``[graph]`` dependencies —
8
+ ``msal``/``httpx`` are imported lazily and only when a Graph call is made.
9
+ """
10
+
11
+ from runspec_windows.graph.auth import get_token
12
+ from runspec_windows.graph.client import graph_get
13
+
14
+ __all__ = ["graph_get", "get_token"]
@@ -0,0 +1,65 @@
1
+ """_platform.py — Windows execution helpers shared by the system runnables.
2
+
3
+ The OS-specific work (shelling out to ``powershell``/``sc``/``netstat`` etc.)
4
+ lives behind these thin helpers so the pure parsers in each module stay
5
+ import-clean and unit-testable on any platform. Every Windows-only runnable
6
+ calls :func:`ensure_windows` first, so running one on a non-Windows host returns
7
+ a structured JSON error instead of a traceback.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import subprocess
14
+ import sys
15
+
16
+ IS_WINDOWS = sys.platform == "win32"
17
+
18
+
19
+ def ensure_windows() -> None:
20
+ """Emit a JSON error and exit if not running on Windows.
21
+
22
+ Keeps non-Windows behaviour predictable (clean error, exit 1) rather than
23
+ crashing on a missing ``ipconfig``/``sc``/Win32 API.
24
+ """
25
+ if not IS_WINDOWS:
26
+ print(json.dumps({"error": "This runnable requires Windows", "platform": sys.platform}))
27
+ sys.exit(1)
28
+
29
+
30
+ def fail(message: str, **extra: object) -> None:
31
+ """Print ``{"error": message, **extra}`` as JSON and exit non-zero."""
32
+ print(json.dumps({"error": message, **extra}))
33
+ sys.exit(1)
34
+
35
+
36
+ def run_powershell(script: str, timeout: int = 30) -> str:
37
+ """Run a PowerShell snippet non-interactively and return its stdout.
38
+
39
+ Raises ``RuntimeError`` with stderr on a non-zero exit so callers can turn
40
+ it into a JSON error.
41
+ """
42
+ result = subprocess.run(
43
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
44
+ capture_output=True,
45
+ text=True,
46
+ timeout=timeout,
47
+ encoding="utf-8",
48
+ errors="replace",
49
+ )
50
+ if result.returncode != 0:
51
+ raise RuntimeError(result.stderr.strip() or f"powershell exited {result.returncode}")
52
+ return result.stdout
53
+
54
+
55
+ def run_cmd(args: list[str], timeout: int = 30, check: bool = False) -> subprocess.CompletedProcess[str]:
56
+ """Run a console command and return the completed process (text mode)."""
57
+ return subprocess.run(
58
+ args,
59
+ capture_output=True,
60
+ text=True,
61
+ timeout=timeout,
62
+ check=check,
63
+ encoding="utf-8",
64
+ errors="replace",
65
+ )
@@ -0,0 +1,87 @@
1
+ """Event log runnables: query-eventlog, recent-errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import runspec as rs
8
+
9
+ from runspec_windows import _platform
10
+
11
+ # Get-WinEvent numeric levels.
12
+ _LEVELS = {"critical": 1, "error": 2, "warning": 3, "information": 4}
13
+
14
+
15
+ def normalize_events(data: object) -> list[dict]:
16
+ """Reshape parsed ``Get-WinEvent | ConvertTo-Json`` output into rows."""
17
+ if isinstance(data, dict):
18
+ items = [data]
19
+ elif isinstance(data, list):
20
+ items = [d for d in data if isinstance(d, dict)]
21
+ else:
22
+ items = []
23
+ rows = []
24
+ for item in items:
25
+ message = item.get("message") or ""
26
+ if isinstance(message, str) and len(message) > 500:
27
+ message = message[:500] + "…"
28
+ rows.append(
29
+ {
30
+ "time": item.get("time"),
31
+ "level": item.get("level"),
32
+ "id": item.get("Id"),
33
+ "provider": item.get("ProviderName"),
34
+ "message": message,
35
+ }
36
+ )
37
+ return rows
38
+
39
+
40
+ def _select() -> str:
41
+ return "Select-Object @{N='time';E={$_.TimeCreated.ToString('s')}},@{N='level';E={$_.LevelDisplayName}},Id,ProviderName,@{N='message';E={$_.Message}}"
42
+
43
+
44
+ def _query(log: str, count: int, level: str | None) -> list[dict]:
45
+ filt = f"LogName='{log}'"
46
+ if level and level != "all":
47
+ filt += f"; Level={_LEVELS[level]}"
48
+ script = f"Get-WinEvent -FilterHashtable @{{{filt}}} -MaxEvents {count} -ErrorAction Stop | {_select()} | ConvertTo-Json -Compress"
49
+ out = _platform.run_powershell(script).strip()
50
+ return normalize_events(json.loads(out)) if out else []
51
+
52
+
53
+ def main_query_eventlog() -> None:
54
+ spec = rs.parse("query-eventlog")
55
+ _platform.ensure_windows()
56
+ log = str(spec.log)
57
+ level = str(spec.level)
58
+ count = int(spec.count)
59
+ try:
60
+ print(json.dumps({"log": log, "level": level, "events": _query(log, count, level)}))
61
+ except RuntimeError as e:
62
+ # Get-WinEvent raises "No events were found" when the filter matches nothing.
63
+ if "No events were found" in str(e):
64
+ print(json.dumps({"log": log, "level": level, "events": []}))
65
+ else:
66
+ _platform.fail(str(e), log=log)
67
+ except Exception as e:
68
+ _platform.fail(str(e), log=log)
69
+
70
+
71
+ def main_recent_errors() -> None:
72
+ spec = rs.parse("recent-errors")
73
+ _platform.ensure_windows()
74
+ count = int(spec.count)
75
+ result: dict[str, list[dict]] = {}
76
+ try:
77
+ for log in ("System", "Application"):
78
+ try:
79
+ result[log] = _query(log, count, "error")
80
+ except RuntimeError as e:
81
+ if "No events were found" in str(e):
82
+ result[log] = []
83
+ else:
84
+ raise
85
+ print(json.dumps(result))
86
+ except Exception as e:
87
+ _platform.fail(str(e))
@@ -0,0 +1,24 @@
1
+ """Microsoft Graph integration for runspec-windows (Outlook + Teams).
2
+
3
+ Pure HTTP via ``httpx`` + delegated auth via ``msal`` device-code flow — no
4
+ pywin32 and no client secret. The optional dependencies live behind the
5
+ ``[graph]`` extra; importing this package never requires them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class GraphError(Exception):
12
+ """Base error for Graph runnables (rendered as a JSON ``error`` payload)."""
13
+
14
+
15
+ class GraphDepError(GraphError):
16
+ """The optional ``[graph]`` dependencies (msal/httpx) are not installed."""
17
+
18
+
19
+ class GraphConfigError(GraphError):
20
+ """No Azure client id configured (RUNSPEC_GRAPH_CLIENT_ID / graph.toml)."""
21
+
22
+
23
+ class GraphAuthError(GraphError):
24
+ """No cached credentials — the user must run ``graph-login`` first."""
@@ -0,0 +1,20 @@
1
+ """_emit.py — shared JSON output + error handling for Graph runnables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from runspec_windows import _platform
10
+ from runspec_windows.graph import GraphError
11
+
12
+
13
+ def run_graph(produce: Callable[[], Any]) -> None:
14
+ """Print ``produce()`` as JSON, turning Graph errors into a JSON error payload."""
15
+ try:
16
+ print(json.dumps(produce()))
17
+ except GraphError as e:
18
+ _platform.fail(str(e), kind=type(e).__name__)
19
+ except Exception as e: # network/JSON/etc.
20
+ _platform.fail(str(e))
@@ -0,0 +1,48 @@
1
+ """Account runnables (Microsoft Graph): graph-login, graph-whoami."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import runspec as rs
8
+
9
+ from runspec_windows import _platform
10
+ from runspec_windows.graph import GraphError
11
+ from runspec_windows.graph._emit import run_graph
12
+ from runspec_windows.graph.auth import device_login
13
+ from runspec_windows.graph.client import graph_get
14
+
15
+
16
+ def main_graph_login() -> None:
17
+ rs.parse("graph-login")
18
+ try:
19
+ result = device_login()
20
+ claims = result.get("id_token_claims", {})
21
+ print(
22
+ json.dumps(
23
+ {
24
+ "signed_in": True,
25
+ "user": claims.get("preferred_username") or claims.get("name"),
26
+ "scopes": result.get("scope"),
27
+ }
28
+ )
29
+ )
30
+ except GraphError as e:
31
+ _platform.fail(str(e), kind=type(e).__name__)
32
+ except Exception as e:
33
+ _platform.fail(str(e))
34
+
35
+
36
+ def main_graph_whoami() -> None:
37
+ rs.parse("graph-whoami")
38
+ run_graph(lambda: _whoami(graph_get("/me", params={"$select": "displayName,userPrincipalName,mail,jobTitle,id"})))
39
+
40
+
41
+ def _whoami(me: dict) -> dict:
42
+ return {
43
+ "display_name": me.get("displayName"),
44
+ "user_principal_name": me.get("userPrincipalName"),
45
+ "mail": me.get("mail"),
46
+ "job_title": me.get("jobTitle"),
47
+ "id": me.get("id"),
48
+ }