audial-mcp 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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ __pycache__/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .superpowers/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Audial
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.5
2
+ Name: audial-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Audial: stem splitting, mastering, MIDI, music generation, resynthesis and vocal synthesis from any MCP client.
5
+ Project-URL: Homepage, https://audialmusic.ai
6
+ Project-URL: Documentation, https://audialmusic.ai/resources/api-reference
7
+ Project-URL: Repository, https://github.com/AudialAI/audial-mcp
8
+ Author-email: Audial <contact@audialmusic.ai>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: audial,audio,mcp,model-context-protocol,music,stems
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Multimedia :: Sound/Audio
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: anyio>=4
17
+ Requires-Dist: audial-sdk>=1.2.3
18
+ Requires-Dist: mcp<3,>=2.2
19
+ Requires-Dist: pydantic>=2
20
+ Requires-Dist: typing-extensions>=4
21
+ Description-Content-Type: text/markdown
22
+
23
+ # audial-mcp
24
+
25
+ Run Audial's hosted audio tools from any MCP client: split stems, analyze and segment audio,
26
+ master tracks, build sample packs, convert audio to MIDI, generate music, turn a one-shot into
27
+ an editable Audial Synth preset, and sing lyrics in a reference voice.
28
+
29
+ mcp-name: io.github.AudialAI/audial-mcp
30
+
31
+ ## Install
32
+
33
+ You need an Audial account (user id + API key from https://audialmusic.ai) and
34
+ [`uv`](https://docs.astral.sh/uv/getting-started/installation/).
35
+
36
+ **Claude Code**
37
+
38
+ ```bash
39
+ claude mcp add audial \
40
+ -e AUDIAL_USER_ID=your-user-id -e AUDIAL_API_KEY=your-api-key -e AUDIAL_RESULTS_DIR=~/Audial \
41
+ -- uvx audial-mcp
42
+ ```
43
+
44
+ **Claude Desktop / Cursor / any client with a JSON config**
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "audial": {
50
+ "command": "uvx",
51
+ "args": ["audial-mcp"],
52
+ "env": {
53
+ "AUDIAL_USER_ID": "your-user-id",
54
+ "AUDIAL_API_KEY": "your-api-key",
55
+ "AUDIAL_RESULTS_DIR": "~/Audial"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Tools
63
+
64
+ | Tool | What it does |
65
+ |---|---|
66
+ | `stem_split` | Split a track into vocals, drums, bass, other (optionally retime / rekey) |
67
+ | `analyze` | BPM, key and other characteristics |
68
+ | `segment` | Sections and component analysis |
69
+ | `master` | Mastering, optionally matched to a reference |
70
+ | `generate_samples` | Sample pack from a track |
71
+ | `generate_midi` | Audio to MIDI |
72
+ | `generate_music` | Text to music, covers, remixes, extraction, completion with the Audial music model |
73
+ | `sound2vital` | One-shot → editable Audial Synth (Vital) preset |
74
+ | `text2vox` | Lyrics + reference voice → sung vocal and MIDI |
75
+ | `list_results` | Browse previous results in your results folder |
76
+
77
+ ## What leaves your machine
78
+
79
+ Audio and text you pass to a tool are uploaded to Audial's API (https://api.audialmusic.ai)
80
+ over HTTPS and processed on Audial's servers; results are downloaded into
81
+ `AUDIAL_RESULTS_DIR` (default `~/Audial`). Nothing else is sent.
82
+
83
+ Two other files are *read* if they exist, because the Audial SDK looks for them:
84
+ `~/.audial/.audial_config.json` and a `.env` in the directory the server was started from.
85
+ Credentials from your MCP client's config always win: `audial-mcp` snapshots its environment
86
+ before the SDK loads either file, so neither can redirect your credentials or the API host.
87
+
88
+ Some tools require an active Audial subscription; the tool tells you when that is the case.
89
+
90
+ ## Configuration
91
+
92
+ | Variable | Required | Default |
93
+ |---|---|---|
94
+ | `AUDIAL_USER_ID` | yes | |
95
+ | `AUDIAL_API_KEY` | yes | |
96
+ | `AUDIAL_RESULTS_DIR` | no | `~/Audial` |
97
+ | `AUDIAL_JOB_TIMEOUT_S` | no | `900` |
98
+ | `AUDIAL_API_BASE_URL` | no | production API |
99
+
100
+ ## License
101
+
102
+ MIT. Source: https://github.com/AudialAI/audial-mcp
@@ -0,0 +1,80 @@
1
+ # audial-mcp
2
+
3
+ Run Audial's hosted audio tools from any MCP client: split stems, analyze and segment audio,
4
+ master tracks, build sample packs, convert audio to MIDI, generate music, turn a one-shot into
5
+ an editable Audial Synth preset, and sing lyrics in a reference voice.
6
+
7
+ mcp-name: io.github.AudialAI/audial-mcp
8
+
9
+ ## Install
10
+
11
+ You need an Audial account (user id + API key from https://audialmusic.ai) and
12
+ [`uv`](https://docs.astral.sh/uv/getting-started/installation/).
13
+
14
+ **Claude Code**
15
+
16
+ ```bash
17
+ claude mcp add audial \
18
+ -e AUDIAL_USER_ID=your-user-id -e AUDIAL_API_KEY=your-api-key -e AUDIAL_RESULTS_DIR=~/Audial \
19
+ -- uvx audial-mcp
20
+ ```
21
+
22
+ **Claude Desktop / Cursor / any client with a JSON config**
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "audial": {
28
+ "command": "uvx",
29
+ "args": ["audial-mcp"],
30
+ "env": {
31
+ "AUDIAL_USER_ID": "your-user-id",
32
+ "AUDIAL_API_KEY": "your-api-key",
33
+ "AUDIAL_RESULTS_DIR": "~/Audial"
34
+ }
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ ## Tools
41
+
42
+ | Tool | What it does |
43
+ |---|---|
44
+ | `stem_split` | Split a track into vocals, drums, bass, other (optionally retime / rekey) |
45
+ | `analyze` | BPM, key and other characteristics |
46
+ | `segment` | Sections and component analysis |
47
+ | `master` | Mastering, optionally matched to a reference |
48
+ | `generate_samples` | Sample pack from a track |
49
+ | `generate_midi` | Audio to MIDI |
50
+ | `generate_music` | Text to music, covers, remixes, extraction, completion with the Audial music model |
51
+ | `sound2vital` | One-shot → editable Audial Synth (Vital) preset |
52
+ | `text2vox` | Lyrics + reference voice → sung vocal and MIDI |
53
+ | `list_results` | Browse previous results in your results folder |
54
+
55
+ ## What leaves your machine
56
+
57
+ Audio and text you pass to a tool are uploaded to Audial's API (https://api.audialmusic.ai)
58
+ over HTTPS and processed on Audial's servers; results are downloaded into
59
+ `AUDIAL_RESULTS_DIR` (default `~/Audial`). Nothing else is sent.
60
+
61
+ Two other files are *read* if they exist, because the Audial SDK looks for them:
62
+ `~/.audial/.audial_config.json` and a `.env` in the directory the server was started from.
63
+ Credentials from your MCP client's config always win: `audial-mcp` snapshots its environment
64
+ before the SDK loads either file, so neither can redirect your credentials or the API host.
65
+
66
+ Some tools require an active Audial subscription; the tool tells you when that is the case.
67
+
68
+ ## Configuration
69
+
70
+ | Variable | Required | Default |
71
+ |---|---|---|
72
+ | `AUDIAL_USER_ID` | yes | |
73
+ | `AUDIAL_API_KEY` | yes | |
74
+ | `AUDIAL_RESULTS_DIR` | no | `~/Audial` |
75
+ | `AUDIAL_JOB_TIMEOUT_S` | no | `900` |
76
+ | `AUDIAL_API_BASE_URL` | no | production API |
77
+
78
+ ## License
79
+
80
+ MIT. Source: https://github.com/AudialAI/audial-mcp
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "audial-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for Audial: stem splitting, mastering, MIDI, music generation, resynthesis and vocal synthesis from any MCP client."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Audial", email = "contact@audialmusic.ai" }]
13
+ keywords = ["mcp", "audio", "music", "stems", "audial", "model-context-protocol"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Topic :: Multimedia :: Sound/Audio",
18
+ ]
19
+ dependencies = [
20
+ "mcp>=2.2,<3",
21
+ "audial-sdk>=1.2.3",
22
+ "anyio>=4",
23
+ "pydantic>=2",
24
+ "typing_extensions>=4",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://audialmusic.ai"
29
+ Documentation = "https://audialmusic.ai/resources/api-reference"
30
+ Repository = "https://github.com/AudialAI/audial-mcp"
31
+
32
+ [project.scripts]
33
+ audial-mcp = "audial_mcp.server:main"
34
+
35
+ [dependency-groups]
36
+ dev = ["pytest>=8", "anyio>=4", "ruff>=0.6", "build>=1.2"]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/audial_mcp"]
40
+
41
+ # The sdist ships the package and its metadata, not the project's working notes: docs/ is
42
+ # design and checklist material, .github/ is CI, .superpowers/ is planning scratch.
43
+ [tool.hatch.build.targets.sdist]
44
+ exclude = ["docs", ".github", ".superpowers"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+
49
+ [tool.ruff]
50
+ line-length = 100
51
+ target-version = "py310"
52
+ # `ruff format` rewrites Python code blocks inside Markdown, which reflows the illustrative
53
+ # snippets in the design and plan documents. Those are prose, not code that runs.
54
+ extend-exclude = ["docs"]
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.AudialAI/audial-mcp",
4
+ "description": "Stem splitting, analysis, mastering, sample packs, audio-to-MIDI, music generation, resynthesis and vocal synthesis with Audial's hosted engines.",
5
+ "version": "0.1.0",
6
+ "repository": { "url": "https://github.com/AudialAI/audial-mcp", "source": "github" },
7
+ "websiteUrl": "https://audialmusic.ai/resources/api-reference",
8
+ "packages": [
9
+ {
10
+ "registryType": "pypi",
11
+ "registryBaseUrl": "https://pypi.org",
12
+ "identifier": "audial-mcp",
13
+ "version": "0.1.0",
14
+ "runtimeHint": "uvx",
15
+ "transport": { "type": "stdio" },
16
+ "environmentVariables": [
17
+ { "name": "AUDIAL_USER_ID", "description": "Audial user id", "isRequired": true, "isSecret": false },
18
+ { "name": "AUDIAL_API_KEY", "description": "Audial API key", "isRequired": true, "isSecret": true },
19
+ { "name": "AUDIAL_RESULTS_DIR", "description": "Folder results are written to", "default": "~/Audial" },
20
+ { "name": "AUDIAL_JOB_TIMEOUT_S", "description": "Seconds to wait for a job before giving up", "default": "900" },
21
+ { "name": "AUDIAL_API_BASE_URL", "description": "Override the Audial API base URL (advanced)", "isRequired": false, "isSecret": false }
22
+ ]
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,15 @@
1
+ """audial-mcp: a local MCP server for the Audial audio tools."""
2
+
3
+ import os
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ # The environment as the client handed it to us, snapshotted before anything else runs.
8
+ #
9
+ # `import audial` calls `dotenv.load_dotenv()`, which copies a `.env` in the *process's working
10
+ # directory* into `os.environ`. The working directory of an MCP server is the client's, not the
11
+ # user's project, and a `.env` that happens to sit there would silently redirect credentials and
12
+ # the API host. This package is imported before `audial_mcp.server` executes its `import audial`
13
+ # (importing a submodule initialises its package first), so this snapshot predates that call.
14
+ # `config.load_settings()` reads from here rather than from the live `os.environ`.
15
+ BOOT_ENV: dict[str, str] = dict(os.environ)
@@ -0,0 +1,4 @@
1
+ from audial_mcp.server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,85 @@
1
+ """Environment-driven settings for the audial MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from audial_mcp import BOOT_ENV
12
+
13
+ DEFAULT_RESULTS_DIR = "~/Audial"
14
+ DEFAULT_JOB_TIMEOUT_S = 900
15
+
16
+ CREDENTIALS_HELP = (
17
+ "AUDIAL_USER_ID and AUDIAL_API_KEY are not set. Add both to the `audial` server's env in "
18
+ "your MCP client config (for Claude Code: `claude mcp add audial -e AUDIAL_USER_ID=... "
19
+ "-e AUDIAL_API_KEY=... -- uvx audial-mcp`). Get them from your dashboard at "
20
+ "https://audialmusic.ai, then restart the client."
21
+ )
22
+
23
+
24
+ class ConfigError(Exception):
25
+ """Raised when the environment cannot be turned into usable settings."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Settings:
30
+ user_id: str | None
31
+ api_key: str | None
32
+ results_dir: Path
33
+ api_base_url: str | None
34
+ job_timeout_s: int
35
+
36
+ def require_credentials(self) -> None:
37
+ if not self.user_id or not self.api_key:
38
+ raise ConfigError(CREDENTIALS_HELP)
39
+
40
+
41
+ def _always_parseable(env: Mapping[str, str]) -> dict[str, Any]:
42
+ """The settings fields that cannot fail to parse: credentials, base URL, results dir."""
43
+ results_dir = Path(
44
+ os.path.expandvars(env.get("AUDIAL_RESULTS_DIR") or DEFAULT_RESULTS_DIR)
45
+ ).expanduser()
46
+ return {
47
+ "user_id": (env.get("AUDIAL_USER_ID") or "").strip() or None,
48
+ "api_key": (env.get("AUDIAL_API_KEY") or "").strip() or None,
49
+ "results_dir": results_dir,
50
+ "api_base_url": (env.get("AUDIAL_API_BASE_URL") or "").strip() or None,
51
+ }
52
+
53
+
54
+ def load_settings(env: Mapping[str, str] | None = None) -> Settings:
55
+ """Read settings from `env`, defaulting to the environment as it was at import time.
56
+
57
+ The default is `audial_mcp.BOOT_ENV`, not the live `os.environ`: the Audial SDK calls
58
+ `dotenv.load_dotenv()` when it is imported, so a `.env` file in whatever directory the MCP
59
+ client happened to launch the server from would otherwise be able to override the
60
+ credentials and API host the client configured.
61
+ """
62
+ env = BOOT_ENV if env is None else env
63
+ raw_timeout = env.get("AUDIAL_JOB_TIMEOUT_S", str(DEFAULT_JOB_TIMEOUT_S))
64
+ try:
65
+ timeout = int(raw_timeout)
66
+ if timeout <= 0:
67
+ raise ValueError
68
+ except ValueError:
69
+ raise ConfigError(
70
+ "AUDIAL_JOB_TIMEOUT_S must be a positive integer number of seconds, "
71
+ f"got {raw_timeout!r}"
72
+ ) from None
73
+
74
+ return Settings(**_always_parseable(env), job_timeout_s=timeout)
75
+
76
+
77
+ def fallback_settings(env: Mapping[str, str] | None = None) -> Settings:
78
+ """Settings to run with when `load_settings` raised: what parsed, defaults for what did not.
79
+
80
+ The server keeps starting on a bad `AUDIAL_JOB_TIMEOUT_S` so that it can list its tools and
81
+ return the configuration error as a readable message. A server that dies at import shows up
82
+ in clients as an opaque "failed to start".
83
+ """
84
+ env = BOOT_ENV if env is None else env
85
+ return Settings(**_always_parseable(env), job_timeout_s=DEFAULT_JOB_TIMEOUT_S)
@@ -0,0 +1,34 @@
1
+ """Turn every failure into a ToolError the model can read and act on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from audial.api.exceptions import AudialAuthError, AudialError, SubscriptionRequiredError
8
+ from mcp.server.mcpserver.exceptions import ToolError
9
+
10
+ from audial_mcp.config import ConfigError
11
+ from audial_mcp.jobs import JobTimeout
12
+ from audial_mcp.validation import ValidationError
13
+
14
+ log = logging.getLogger("audial_mcp")
15
+
16
+
17
+ def to_tool_error(exc: BaseException, *, tool: str, execution_id: str | None = None) -> ToolError:
18
+ if isinstance(exc, (ConfigError, ValidationError, SubscriptionRequiredError)):
19
+ return ToolError(str(exc))
20
+ if isinstance(exc, AudialAuthError):
21
+ return ToolError(
22
+ "Audial rejected the credentials. Check AUDIAL_USER_ID and AUDIAL_API_KEY in the "
23
+ "server's env and restart the client."
24
+ )
25
+ if isinstance(exc, JobTimeout):
26
+ ref = f" (execution {execution_id})" if execution_id else ""
27
+ return ToolError(
28
+ f"{exc.label} did not finish within {exc.elapsed_s:.0f} s{ref}. The job may still "
29
+ "complete on Audial's side; try again, or use a shorter file."
30
+ )
31
+ if isinstance(exc, AudialError):
32
+ return ToolError(str(exc))
33
+ log.exception("%s failed", tool)
34
+ return ToolError(f"{tool} failed unexpectedly. Details were logged to the server's stderr.")
@@ -0,0 +1,72 @@
1
+ """Run blocking SDK calls without freezing the MCP connection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from typing import Any, TypeVar
8
+
9
+ import anyio
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ class JobTimeout(Exception):
15
+ def __init__(self, label: str, elapsed_s: float):
16
+ super().__init__(f"{label} did not finish within {elapsed_s:.0f} s")
17
+ self.label = label
18
+ self.elapsed_s = elapsed_s
19
+
20
+
21
+ async def run_job(
22
+ ctx: Any,
23
+ label: str,
24
+ fn: Callable[[], T],
25
+ *,
26
+ timeout_s: float,
27
+ heartbeat_s: float = 5.0,
28
+ ) -> T:
29
+ """Run `fn` in a worker thread; report elapsed seconds as progress until it returns."""
30
+ started = time.monotonic()
31
+
32
+ async def heartbeat() -> None:
33
+ while True:
34
+ await anyio.sleep(heartbeat_s)
35
+ elapsed = int(time.monotonic() - started)
36
+ try:
37
+ # Progress is best-effort: a disconnected client must not
38
+ # decide the job's outcome, so swallow reporting failures here
39
+ # rather than let them cross the task group as an error.
40
+ await ctx.report_progress(elapsed, message=f"{label}: {elapsed} s elapsed")
41
+ except Exception:
42
+ return
43
+
44
+ # Exceptions (other than cancellation) are caught here rather than left to
45
+ # propagate out of the `async with` block: anyio's TaskGroup.__aexit__
46
+ # unconditionally wraps any non-CancelledError exception raised in the
47
+ # block's body in a BaseExceptionGroup, even when there is only one.
48
+ # Catching and re-raising after the block exits keeps the caller-facing
49
+ # exception a bare JobTimeout / the original error. CancelledError is left
50
+ # to propagate normally; anyio already excludes it from that wrapping.
51
+ #
52
+ # A bare `except TimeoutError` here would be wrong: fn itself may raise a
53
+ # TimeoutError (socket.timeout / concurrent.futures.TimeoutError are
54
+ # aliases of it), which must propagate as itself, not get relabeled as a
55
+ # JobTimeout. So the deadline is detected via move_on_after's
56
+ # cancelled_caught instead of by catching an exception type.
57
+ error: Exception | None = None
58
+ result: T | None = None
59
+ async with anyio.create_task_group() as tg:
60
+ tg.start_soon(heartbeat)
61
+ with anyio.move_on_after(timeout_s) as scope:
62
+ try:
63
+ # Cancelling to_thread does not stop the thread; it finishes quietly.
64
+ result = await anyio.to_thread.run_sync(fn, abandon_on_cancel=True)
65
+ except Exception as exc:
66
+ error = exc
67
+ tg.cancel_scope.cancel()
68
+ if scope.cancelled_caught:
69
+ raise JobTimeout(label, time.monotonic() - started)
70
+ if error is not None:
71
+ raise error
72
+ return result
@@ -0,0 +1,88 @@
1
+ """Where results live and how they are reported back to the model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from typing_extensions import TypedDict
11
+
12
+ _SLUG_RE = re.compile(r"[^A-Za-z0-9_-]+")
13
+ SLUG_MAX = 40
14
+
15
+
16
+ class FileInfo(TypedDict):
17
+ name: str
18
+ path: str
19
+ size_bytes: int
20
+
21
+
22
+ class JobResult(TypedDict):
23
+ tool: str
24
+ execution_id: str | None
25
+ output_dir: str
26
+ files: list[FileInfo]
27
+ metadata: dict[str, Any]
28
+ summary: str
29
+
30
+
31
+ def slugify(text: str, fallback: str = "job") -> str:
32
+ slug = _SLUG_RE.sub("_", text.strip()).strip("_")[:SLUG_MAX]
33
+ return slug or fallback
34
+
35
+
36
+ def new_job_dir(results_dir: Path, tool: str, slug: str, now: datetime | None = None) -> Path:
37
+ stamp = (now or datetime.now()).strftime("%Y-%m-%d_%H%M%S")
38
+ folder = results_dir / tool / f"{stamp}_{slug}"
39
+ folder.mkdir(parents=True, exist_ok=True)
40
+ return folder
41
+
42
+
43
+ def inventory(folder: Path) -> list[FileInfo]:
44
+ if not folder.is_dir():
45
+ return []
46
+ files: list[FileInfo] = []
47
+ for path in sorted(folder.rglob("*")):
48
+ rel_path = path.relative_to(folder)
49
+ if not path.is_file() or any(part.startswith(".") for part in rel_path.parts):
50
+ continue
51
+ files.append(
52
+ FileInfo(
53
+ name=rel_path.as_posix(),
54
+ path=str(path.resolve()),
55
+ size_bytes=path.stat().st_size,
56
+ )
57
+ )
58
+ return files
59
+
60
+
61
+ def list_jobs(results_dir: Path, tool: str | None, limit: int) -> list[JobResult]:
62
+ if not results_dir.is_dir():
63
+ return []
64
+ tools = [tool] if tool else sorted(p.name for p in results_dir.iterdir() if p.is_dir())
65
+ jobs: list[tuple[str, JobResult]] = []
66
+ for name in tools:
67
+ tool_dir = results_dir / name
68
+ if not tool_dir.is_dir():
69
+ continue
70
+ for job_dir in tool_dir.iterdir():
71
+ if not job_dir.is_dir():
72
+ continue
73
+ files = inventory(job_dir)
74
+ jobs.append(
75
+ (
76
+ job_dir.name,
77
+ JobResult(
78
+ tool=name,
79
+ execution_id=None,
80
+ output_dir=str(job_dir),
81
+ files=files,
82
+ metadata={},
83
+ summary=f"{name}: {len(files)} file(s) in {job_dir}",
84
+ ),
85
+ )
86
+ )
87
+ jobs.sort(key=lambda item: item[0], reverse=True)
88
+ return [job for _, job in jobs[: max(limit, 0)]]