runspec-fs 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,61 @@
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
59
+
60
+ # Stray committed test venv (removed from tracking)
61
+ .venv-test/
@@ -0,0 +1,30 @@
1
+ # runspec-fs Changelog
2
+
3
+ ## [0.1.0] — 2026-07-25
4
+
5
+ Initial release.
6
+
7
+ A single runnable, `fs`, with a verb per tool of the
8
+ [Filesystem MCP server](https://github.com/modelcontextprotocol/servers/tree/HEAD/src/filesystem)
9
+ — read / write / edit / list / tree / search / move / stat. `runspec serve`
10
+ re-expands each verb into its own leaf MCP tool (`fs_read-text-file`,
11
+ `fs_write-file`, …), so a runspec-console agent can call them individually over
12
+ SSH on any fleet host — giving it local **and** remote filesystem access with
13
+ per-verb confirm gates and no extra process on the host.
14
+
15
+ - **Reads (autonomous)** — `read-text-file` (`--head` / `--tail`),
16
+ `read-media-file` (base64 + MIME), `read-multiple-files`, `list-directory`,
17
+ `list-directory-with-sizes` (`--sort-by name|size`), `directory-tree`
18
+ (`--exclude`), `search-files` (glob or substring, `--exclude`), `get-file-info`,
19
+ `list-allowed-directories`.
20
+ - **Mutations (confirm)** — `write-file`, `edit-file`, `create-directory`,
21
+ `move-file`.
22
+
23
+ `edit-file` takes its edits as two parallel repeatable args — `--old-text` and
24
+ `--new-text`, paired by position — rather than a JSON blob, so agents pass two
25
+ typed string arrays (no new runspec arg type was needed).
26
+
27
+ **No directory jail** — filesystem access is bounded by the OS permissions of the
28
+ user the process runs as (the logged-in user, or a `run_as` service account).
29
+ The pure logic lives in `runspec-fs-core` (no runspec dependency), re-exported
30
+ here for corporate-wrapping.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-fs
3
+ Version: 0.1.0
4
+ Summary: Filesystem runnable for runspec — read and manipulate local/remote filesystems, ported from the Filesystem MCP server
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: runspec-fs-core>=0.1.0
7
+ Requires-Dist: runspec>=0.44.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: mypy; extra == 'dev'
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-fs"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ description = "Filesystem runnable for runspec — read and manipulate local/remote filesystems, ported from the Filesystem MCP server"
10
+ dependencies = [
11
+ # 0.44.0 surfaces inherited args on each leaf subcommand's MCP tool via
12
+ # `runspec serve`; fs has no globals, but the floor keeps serve behaviour
13
+ # consistent with the other subcommand runnables.
14
+ "runspec>=0.44.0",
15
+ "runspec-fs-core>=0.1.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ # Entry point name MUST match the runnable name in runspec.toml (`fs`).
20
+ fs = "runspec_fs.cli:main"
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "ruff==0.15.20",
25
+ "mypy",
26
+ "pytest>=8.0",
27
+ ]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
31
+
32
+ [tool.mypy]
33
+ python_version = "3.10"
34
+
35
+ [tool.ruff]
36
+ line-length = 200
37
+ target-version = "py310"
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,48 @@
1
+ """runspec-fs — filesystem operations as a runspec runnable (the ``fs`` runnable).
2
+
3
+ Ported from the
4
+ `Filesystem MCP server <https://github.com/modelcontextprotocol/servers/tree/HEAD/src/filesystem>`_.
5
+ The pure logic lives in ``runspec-fs-core`` (importable with **no** runspec
6
+ dependency); this package wraps each helper as a verb of the ``fs`` runnable and
7
+ is what ``runspec local`` / ``runspec serve`` discover.
8
+
9
+ The core helpers are re-exported here for convenience / back-compat, so a caller
10
+ that already depends on ``runspec-fs`` can ``from runspec_fs import
11
+ read_text_file`` without also naming ``runspec-fs-core``:
12
+ """
13
+
14
+ from runspec_fs_core import (
15
+ EditNotAppliedError,
16
+ FsCoreError,
17
+ allowed_directories,
18
+ create_directory,
19
+ directory_tree,
20
+ edit_file,
21
+ get_file_info,
22
+ list_directory,
23
+ list_directory_with_sizes,
24
+ move_file,
25
+ read_media_file,
26
+ read_multiple_files,
27
+ read_text_file,
28
+ search_files,
29
+ write_file,
30
+ )
31
+
32
+ __all__ = [
33
+ "FsCoreError",
34
+ "EditNotAppliedError",
35
+ "read_text_file",
36
+ "read_media_file",
37
+ "read_multiple_files",
38
+ "write_file",
39
+ "edit_file",
40
+ "create_directory",
41
+ "list_directory",
42
+ "list_directory_with_sizes",
43
+ "directory_tree",
44
+ "move_file",
45
+ "search_files",
46
+ "get_file_info",
47
+ "allowed_directories",
48
+ ]
@@ -0,0 +1,95 @@
1
+ """cli.py — the ``fs`` entry point.
2
+
3
+ One binary, a verb per Filesystem MCP tool. ``rs.parse("fs")`` resolves the
4
+ subcommand and its args; we dispatch on ``spec.runspec_command``, unpack each
5
+ ``.value`` at the boundary, and delegate the actual work to
6
+ :mod:`runspec_fs_core`. Failures (the ``runspec_fs_core`` errors, the stdlib
7
+ ``OSError`` family, and arg-shape ``ValueError``s such as an ``--old-text`` /
8
+ ``--new-text`` count mismatch) become a JSON ``{"error": ...}`` payload + a
9
+ non-zero exit — the shape the CLI/agent surface expects.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from typing import Any
17
+
18
+ import runspec as rs
19
+ from runspec_fs_core import (
20
+ allowed_directories,
21
+ create_directory,
22
+ directory_tree,
23
+ edit_file,
24
+ get_file_info,
25
+ list_directory,
26
+ list_directory_with_sizes,
27
+ move_file,
28
+ read_media_file,
29
+ read_multiple_files,
30
+ read_text_file,
31
+ search_files,
32
+ write_file,
33
+ )
34
+ from runspec_fs_core.errors import FsCoreError
35
+
36
+
37
+ def _emit(payload: dict[str, Any]) -> None:
38
+ print(json.dumps(payload, default=str))
39
+
40
+
41
+ def _pair_edits(old_text: list[str], new_text: list[str]) -> list[tuple[str, str]]:
42
+ """Zip the two parallel ``--old-text`` / ``--new-text`` lists into
43
+ ``(old, new)`` pairs, erroring if their counts differ."""
44
+ if len(old_text) != len(new_text):
45
+ raise ValueError(f"--old-text / --new-text count mismatch: {len(old_text)} vs {len(new_text)} (pair them by position)")
46
+ return list(zip(old_text, new_text, strict=False))
47
+
48
+
49
+ def _dispatch(spec: rs.RunSpec) -> dict[str, Any]:
50
+ command = spec.runspec_command
51
+
52
+ if command == "read-text-file":
53
+ return read_text_file(spec.path.value, head=spec.head.value, tail=spec.tail.value)
54
+ if command == "read-media-file":
55
+ return read_media_file(spec.path.value)
56
+ if command == "read-multiple-files":
57
+ return read_multiple_files(spec.paths.value)
58
+ if command == "list-directory":
59
+ return list_directory(spec.path.value)
60
+ if command == "list-directory-with-sizes":
61
+ return list_directory_with_sizes(spec.path.value, sort_by=spec.sort_by.value)
62
+ if command == "directory-tree":
63
+ return directory_tree(spec.path.value, exclude=spec.exclude.value or None)
64
+ if command == "search-files":
65
+ return search_files(spec.path.value, spec.pattern.value, exclude=spec.exclude.value or None)
66
+ if command == "get-file-info":
67
+ return get_file_info(spec.path.value)
68
+ if command == "list-allowed-directories":
69
+ return allowed_directories()
70
+ if command == "write-file":
71
+ return write_file(spec.path.value, spec.content.value)
72
+ if command == "edit-file":
73
+ edits = _pair_edits(spec.old_text.value, spec.new_text.value)
74
+ return edit_file(spec.path.value, edits, dry_run=spec.dry_run.value)
75
+ if command == "create-directory":
76
+ return create_directory(spec.path.value)
77
+ if command == "move-file":
78
+ return move_file(spec.source.value, spec.destination.value)
79
+
80
+ # require-command = true makes a bare invocation unreachable.
81
+ raise SystemExit("no command — see `fs --help`") # pragma: no cover
82
+
83
+
84
+ def main() -> None:
85
+ spec = rs.parse("fs")
86
+ try:
87
+ result = _dispatch(spec)
88
+ except (FsCoreError, OSError, ValueError) as exc:
89
+ _emit({"error": str(exc), "kind": type(exc).__name__})
90
+ sys.exit(1)
91
+ _emit(result)
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
@@ -0,0 +1,123 @@
1
+ #:schema https://runspec.app/runspec.schema.json
2
+
3
+ # runspec-fs — filesystem operations as a runspec runnable, ported from the
4
+ # Filesystem MCP server:
5
+ # https://github.com/modelcontextprotocol/servers/tree/HEAD/src/filesystem
6
+ #
7
+ # One runnable, `fs`, with a verb per MCP tool. `runspec serve` re-expands each
8
+ # verb into its own leaf MCP tool (fs_read-text-file, fs_write-file, …), so a
9
+ # runspec-console agent calls them individually — over SSH on any fleet host,
10
+ # giving local AND remote filesystem access with per-verb confirm gates.
11
+ #
12
+ # No directory jail is enforced: access is bounded by the OS permissions of the
13
+ # user the process runs as (the logged-in user, or a `run_as` service account).
14
+ # A wrong path the agent picks and a human confirms still can't exceed what that
15
+ # user could already do on the host.
16
+
17
+ [config]
18
+ autonomy-default = "confirm"
19
+
20
+ [config.logging]
21
+ store = "per-run"
22
+
23
+ [fs]
24
+ description = "Read and manipulate the filesystem on this host (local, or remote via runspec-console)"
25
+ output = "json"
26
+ require-command = true
27
+ examples = [
28
+ {cmd = "fs list-directory --path .", description = "List the current directory"},
29
+ {cmd = "fs read-text-file --path README.md --head 20", description = "Read the first 20 lines of a file"},
30
+ {cmd = "fs search-files --path . --pattern '*.py'", description = "Find Python files recursively"},
31
+ {cmd = "fs edit-file --path a.txt --old-text foo --new-text bar", description = "Replace 'foo' with 'bar'"},
32
+ ]
33
+
34
+ # ── reads / metadata → autonomous ──────────────────────────────────────────
35
+
36
+ [fs.commands.read-text-file]
37
+ description = "Read a file as UTF-8 text; optionally only the first N (--head) or last N (--tail) lines"
38
+ autonomy = "autonomous"
39
+ [fs.commands.read-text-file.args]
40
+ path = {type = "path", required = true, description = "File to read"}
41
+ head = {type = "int", required = false, description = "Return only the first N lines"}
42
+ tail = {type = "int", required = false, description = "Return only the last N lines"}
43
+
44
+ [fs.commands.read-media-file]
45
+ description = "Read a binary/media file and return it base64-encoded with its MIME type"
46
+ autonomy = "autonomous"
47
+ [fs.commands.read-media-file.args]
48
+ path = {type = "path", required = true, description = "File to read"}
49
+
50
+ [fs.commands.read-multiple-files]
51
+ description = "Read several files at once; a failed read is reported per-file without aborting the batch"
52
+ autonomy = "autonomous"
53
+ [fs.commands.read-multiple-files.args]
54
+ paths = {type = "path", multiple = true, required = true, description = "Files to read (repeat --paths per file)"}
55
+
56
+ [fs.commands.list-directory]
57
+ description = "List a directory's immediate contents, each tagged file or directory"
58
+ autonomy = "autonomous"
59
+ [fs.commands.list-directory.args]
60
+ path = {type = "path", required = true, description = "Directory to list"}
61
+
62
+ [fs.commands.list-directory-with-sizes]
63
+ description = "List a directory's contents with per-entry sizes and totals"
64
+ autonomy = "autonomous"
65
+ [fs.commands.list-directory-with-sizes.args]
66
+ path = {type = "path", required = true, description = "Directory to list"}
67
+ sort-by = {type = "choice", options = ["name", "size"], default = "name", description = "Sort order (size is descending)"}
68
+
69
+ [fs.commands.directory-tree]
70
+ description = "Return a recursive JSON tree of a directory"
71
+ autonomy = "autonomous"
72
+ [fs.commands.directory-tree.args]
73
+ path = {type = "path", required = true, description = "Directory to walk"}
74
+ exclude = {type = "str", multiple = true, required = false, description = "Glob patterns to prune (repeat --exclude)"}
75
+
76
+ [fs.commands.search-files]
77
+ description = "Recursively find files/directories whose name matches a pattern (glob, or case-insensitive substring)"
78
+ autonomy = "autonomous"
79
+ [fs.commands.search-files.args]
80
+ path = {type = "path", required = true, description = "Directory to search under"}
81
+ pattern = {type = "str", required = true, description = "Glob (e.g. '*.py') or a case-insensitive substring"}
82
+ exclude = {type = "str", multiple = true, required = false, description = "Glob patterns to prune (repeat --exclude)"}
83
+
84
+ [fs.commands.get-file-info]
85
+ description = "Return detailed metadata for a file or directory (size, timestamps, permissions)"
86
+ autonomy = "autonomous"
87
+ [fs.commands.get-file-info.args]
88
+ path = {type = "path", required = true, description = "File or directory to stat"}
89
+
90
+ [fs.commands.list-allowed-directories]
91
+ description = "Report the effective user and starting directories (informational — no directory jail is enforced)"
92
+ autonomy = "autonomous"
93
+
94
+ # ── mutations → confirm ────────────────────────────────────────────────────
95
+
96
+ [fs.commands.write-file]
97
+ description = "Create a new file or overwrite an existing one with UTF-8 text"
98
+ autonomy = "confirm"
99
+ [fs.commands.write-file.args]
100
+ path = {type = "path", required = true, description = "File to write"}
101
+ content = {type = "str", required = true, description = "File contents"}
102
+
103
+ [fs.commands.edit-file]
104
+ description = "Apply ordered old→new substring replacements to a file; returns a unified diff. Pair each --old-text with a --new-text by position."
105
+ autonomy = "confirm"
106
+ [fs.commands.edit-file.args]
107
+ path = {type = "path", required = true, description = "File to edit"}
108
+ old-text = {type = "str", multiple = true, required = true, description = "Text to find (repeat --old-text; paired by position with --new-text)"}
109
+ new-text = {type = "str", multiple = true, required = true, description = "Replacement text (repeat --new-text; paired by position with --old-text)"}
110
+ dry-run = {type = "flag", default = false, description = "Compute and diff the edits without writing"}
111
+
112
+ [fs.commands.create-directory]
113
+ description = "Create a directory (and any missing parents); idempotent"
114
+ autonomy = "confirm"
115
+ [fs.commands.create-directory.args]
116
+ path = {type = "path", required = true, description = "Directory to create"}
117
+
118
+ [fs.commands.move-file]
119
+ description = "Move or rename a file/directory; fails if the destination already exists"
120
+ autonomy = "confirm"
121
+ [fs.commands.move-file.args]
122
+ source = {type = "path", required = true, description = "Path to move"}
123
+ destination = {type = "path", required = true, description = "New path (must not already exist)"}
File without changes
@@ -0,0 +1,52 @@
1
+ """Wrapper-only logic (the edit-pairing) plus an end-to-end run of a couple of
2
+ verbs through the installed `fs` entry point against tmp_path."""
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from runspec_fs.cli import _pair_edits
13
+
14
+
15
+ def test_pair_edits_ok() -> None:
16
+ assert _pair_edits(["a", "b"], ["x", "y"]) == [("a", "x"), ("b", "y")]
17
+
18
+
19
+ def test_pair_edits_mismatch_raises() -> None:
20
+ with pytest.raises(ValueError, match="count mismatch"):
21
+ _pair_edits(["a"], ["x", "y"])
22
+
23
+
24
+ def _run_fs(argv: list[str], tmp_path: Path) -> dict:
25
+ """Invoke the runnable in-process via `python -m runspec_fs.cli`, pointing
26
+ runspec at the packaged spec. Returns the parsed JSON stdout."""
27
+ spec = Path(__file__).parent.parent / "runspec_fs" / "runspec.toml"
28
+ env = {**os.environ, "RUNSPEC_CONFIG": str(spec)}
29
+ proc = subprocess.run(
30
+ [sys.executable, "-c", "from runspec_fs.cli import main; main()", *argv],
31
+ capture_output=True,
32
+ text=True,
33
+ env=env,
34
+ cwd=tmp_path,
35
+ )
36
+ assert proc.returncode == 0, proc.stderr
37
+ return json.loads(proc.stdout.strip().splitlines()[-1])
38
+
39
+
40
+ def test_list_directory_end_to_end(tmp_path: Path) -> None:
41
+ (tmp_path / "hello.txt").write_text("hi")
42
+ out = _run_fs(["list-directory", "--path", str(tmp_path)], tmp_path)
43
+ names = {e["name"] for e in out["entries"]}
44
+ assert "hello.txt" in names
45
+
46
+
47
+ def test_read_text_file_head_end_to_end(tmp_path: Path) -> None:
48
+ f = tmp_path / "a.txt"
49
+ f.write_text("l1\nl2\nl3\n")
50
+ out = _run_fs(["read-text-file", "--path", str(f), "--head", "2"], tmp_path)
51
+ assert out["content"] == "l1\nl2\n"
52
+ assert out["truncated"] is True
@@ -0,0 +1,74 @@
1
+ """Wiring / parsing tests for the `fs` runnable — no mocks, real spec."""
2
+
3
+ from pathlib import Path
4
+
5
+ from runspec.testing import ParseHarness
6
+
7
+ SPEC = Path(__file__).parent.parent / "runspec_fs" / "runspec.toml"
8
+
9
+
10
+ def _harness() -> ParseHarness:
11
+ return ParseHarness(script_name="fs", config_path=SPEC)
12
+
13
+
14
+ def test_smoke_all_verbs() -> None:
15
+ h = _harness()
16
+ # Walk the whole verb tree: --help exits 0, require-command is enforced,
17
+ # omitting required args exits 1, and a fully-populated invocation parses.
18
+ h.smoke(
19
+ values={
20
+ "path": ".",
21
+ "pattern": "x",
22
+ "content": "hi",
23
+ "paths": ["."],
24
+ "old-text": ["a"],
25
+ "new-text": ["b"],
26
+ }
27
+ )
28
+ h.close()
29
+
30
+
31
+ def test_read_text_file_coerces() -> None:
32
+ h = _harness()
33
+ spec = h.expect_ok(["read-text-file", "--path", "README.md", "--head", "5"])
34
+ assert spec.runspec_command == "read-text-file"
35
+ assert spec.head.value == 5
36
+ assert spec.tail.value is None
37
+ h.close()
38
+
39
+
40
+ def test_bad_choice_rejected() -> None:
41
+ h = _harness()
42
+ h.expect_exit(
43
+ ["list-directory-with-sizes", "--path", ".", "--sort-by", "nope"],
44
+ contains="sort-by",
45
+ )
46
+ h.close()
47
+
48
+
49
+ def test_missing_required_path_rejected() -> None:
50
+ h = _harness()
51
+ h.expect_exit(["list-directory"], contains="path")
52
+ h.close()
53
+
54
+
55
+ def test_edit_file_repeated_args_build_lists() -> None:
56
+ h = _harness()
57
+ spec = h.expect_ok(
58
+ [
59
+ "edit-file",
60
+ "--path",
61
+ "a.txt",
62
+ "--old-text",
63
+ "foo",
64
+ "--old-text",
65
+ "baz",
66
+ "--new-text",
67
+ "bar",
68
+ "--new-text",
69
+ "qux",
70
+ ]
71
+ )
72
+ assert spec.old_text.value == ["foo", "baz"]
73
+ assert spec.new_text.value == ["bar", "qux"]
74
+ h.close()