skillstate-kit 0.1.1__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.
skillstate/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Portable state for agent skills. Imports never start models or modify hosts."""
2
+
3
+ from .errors import (
4
+ BudgetExceeded,
5
+ ConflictError,
6
+ NotFoundError,
7
+ OperationUncertain,
8
+ SkillStateError,
9
+ SourceChanged,
10
+ StepLimitExceeded,
11
+ ValidationError,
12
+ )
13
+ from .models import Limits, Skill, Tool, ToolResult
14
+ from .runtime import SkillRuntime
15
+ from .schema import apply_patch
16
+ from .store import SQLiteStore
17
+
18
+ __version__ = "0.1.1"
19
+ __all__ = [
20
+ "BudgetExceeded",
21
+ "ConflictError",
22
+ "Limits",
23
+ "NotFoundError",
24
+ "OperationUncertain",
25
+ "Skill",
26
+ "SkillStateError",
27
+ "SourceChanged",
28
+ "StepLimitExceeded",
29
+ "Tool",
30
+ "ToolResult",
31
+ "ValidationError",
32
+ "apply_patch",
33
+ "SkillRuntime",
34
+ "SQLiteStore",
35
+ ]
skillstate/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,55 @@
1
+ """Content-addressed artifacts, separate from the model's execution context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from .errors import BudgetExceeded, NotFoundError, ValidationError
9
+ from .jsonio import atomic_write, digest, within
10
+
11
+
12
+ class ArtifactStore:
13
+ def __init__(self, project: Path):
14
+ self.root = within(project, ".skillstate/local/artifacts")
15
+
16
+ def put(self, content: str) -> str:
17
+ if type(content) is not str:
18
+ raise ValidationError("Artifact content must be text")
19
+ raw = content.encode("utf-8")
20
+ if len(raw) > 2_000_000:
21
+ raise BudgetExceeded("Artifact exceeds 2000000 bytes")
22
+ key = digest(raw)
23
+ path = within(self.root, key + ".txt")
24
+ if path.exists() and digest(path.read_bytes()) != key:
25
+ raise ValidationError("Existing artifact integrity check failed")
26
+ if not path.exists():
27
+ atomic_write(path, raw)
28
+ return key
29
+
30
+ def read(self, key: str, offset: int = 0, length: int = 4000) -> dict:
31
+ if type(key) is not str or not re.fullmatch(r"[a-f0-9]{64}", key):
32
+ raise ValidationError("Invalid artifact ID")
33
+ if (
34
+ type(offset) is not int
35
+ or offset < 0
36
+ or type(length) is not int
37
+ or not 1 <= length <= 8000
38
+ ):
39
+ raise ValidationError("Invalid artifact character range")
40
+ path = within(self.root, key + ".txt")
41
+ if not path.is_file():
42
+ raise NotFoundError("Artifact not found")
43
+ if path.stat().st_size > 2_000_000:
44
+ raise BudgetExceeded("Artifact exceeds storage budget")
45
+ raw = path.read_bytes()
46
+ if digest(raw) != key:
47
+ raise ValidationError("Artifact integrity check failed")
48
+ content = raw.decode("utf-8")
49
+ return {
50
+ "id": key,
51
+ "offset": offset,
52
+ "content": content[offset : offset + length],
53
+ "next_offset": min(offset + length, len(content)),
54
+ "total_characters": len(content),
55
+ }
skillstate/cli.py ADDED
@@ -0,0 +1,256 @@
1
+ """JSON-first command line interface for people and coding agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import sqlite3
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from . import __version__, compiler, hosts
14
+ from .artifacts import ArtifactStore
15
+ from .demo import run_demo
16
+ from .errors import SkillStateError, ValidationError
17
+ from .jsonio import dumps, read_json, within
18
+ from .service import ProjectService
19
+
20
+
21
+ def parser() -> argparse.ArgumentParser:
22
+ p = argparse.ArgumentParser(
23
+ prog="skillstate", description="Portable, validated execution state for agent skills"
24
+ )
25
+ p.add_argument("--version", action="version", version=__version__)
26
+ p.add_argument(
27
+ "--project", type=Path, default=Path.cwd(), help="Project root (before the subcommand)"
28
+ )
29
+ subs = p.add_subparsers(dest="command", required=True)
30
+ init = subs.add_parser("init", help="Install project-scoped generator skills")
31
+ init.add_argument("--host", action="append", choices=hosts.HOSTS)
32
+ init.add_argument(
33
+ "--mcp",
34
+ action="store_true",
35
+ help="Also merge local MCP configurations (requires mcp extra)",
36
+ )
37
+ gen = subs.add_parser(
38
+ "generate", help="Convert a source skill or prepare a semantic generation request"
39
+ )
40
+ gen.add_argument("source", nargs="?", default=".")
41
+ gen.add_argument("--name")
42
+ gen.add_argument("--profile", choices=("auto", "tracking", "python-tests"), default="auto")
43
+ mode = gen.add_mutually_exclusive_group()
44
+ mode.add_argument("--prepare", action="store_true")
45
+ mode.add_argument("--proposal", type=Path)
46
+ mode.add_argument("--base-url", help="Configured JSON chat endpoint, including /v1 if required")
47
+ gen.add_argument("--model")
48
+ gen.add_argument("--api-key-env", default="SKILLSTATE_API_KEY")
49
+ gen.add_argument("--source-hash", help="Required with --proposal")
50
+ gen.add_argument("--install", action="store_true")
51
+ check = subs.add_parser(
52
+ "validate", help="Validate bundle integrity, schema and source freshness"
53
+ )
54
+ check.add_argument("name")
55
+ doctor = subs.add_parser(
56
+ "doctor", help="Check managed files and local integration configuration"
57
+ )
58
+ doctor.add_argument(
59
+ "--mcp", action="store_true", help="Also perform a real local MCP handshake"
60
+ )
61
+ subs.add_parser("uninstall", help="Remove unmodified managed host files; retain run data")
62
+ subs.add_parser("status", help="List local runs")
63
+ subs.add_parser("demo", help="Run the explicit offline scripted-model example")
64
+ subs.add_parser("serve", help="Start a project-scoped STDIO MCP server")
65
+ run = subs.add_parser("run", help="Operate a durable run")
66
+ r = run.add_subparsers(dest="operation", required=True)
67
+ open_p = r.add_parser("open")
68
+ open_p.add_argument("name")
69
+ open_p.add_argument("--owner", required=True)
70
+ open_p.add_argument("--id")
71
+ open_p.add_argument("--observation", type=Path)
72
+ for command in ("context", "events"):
73
+ child = r.add_parser(command)
74
+ child.add_argument("run_id")
75
+ if command == "events":
76
+ child.add_argument("--after", type=int, default=0)
77
+ update = r.add_parser("update")
78
+ reserve = r.add_parser("reserve")
79
+ handoff = r.add_parser("handoff")
80
+ for child in (update, reserve, handoff):
81
+ child.add_argument("run_id")
82
+ child.add_argument("--owner", required=True)
83
+ child.add_argument("--revision", type=int, required=True)
84
+ update.add_argument("--patch", type=Path, required=True)
85
+ update.add_argument("--observation", type=Path)
86
+ update.add_argument("--done", action="store_true")
87
+ reserve.add_argument("--decision", type=Path, required=True)
88
+ handoff.add_argument("--to", required=True)
89
+ result = r.add_parser("result")
90
+ unknown = r.add_parser("unknown")
91
+ for child in (result, unknown):
92
+ child.add_argument("operation_id")
93
+ child.add_argument("--owner", required=True)
94
+ result.add_argument("--result", type=Path, required=True)
95
+ result.add_argument("--reconcile", action="store_true")
96
+ unknown.add_argument("--reason", default="Host could not determine the tool outcome")
97
+ artifact = subs.add_parser("artifact")
98
+ a = artifact.add_subparsers(dest="operation", required=True)
99
+ put = a.add_parser("put")
100
+ put.add_argument("file", type=Path)
101
+ read = a.add_parser("read")
102
+ read.add_argument("id")
103
+ read.add_argument("--offset", type=int, default=0)
104
+ read.add_argument("--length", type=int, default=4000)
105
+ return p
106
+
107
+
108
+ def dispatch(args) -> dict | list | None:
109
+ project = args.project.resolve()
110
+ service = ProjectService(project)
111
+
112
+ def read(path):
113
+ return read_json(within(project, path)) if path else None
114
+
115
+ if args.command == "init":
116
+ return hosts.install(project, args.host, mcp=args.mcp)
117
+ if args.command == "generate":
118
+ if args.prepare:
119
+ return compiler.prepare(project, args.source, args.name)
120
+ proposal, source_hash = read(args.proposal), args.source_hash
121
+ if proposal is not None and not source_hash:
122
+ raise ValidationError("--proposal requires --source-hash from the prepare response")
123
+ if args.base_url:
124
+ from .providers import JSONChatModel
125
+
126
+ request = compiler.prepare(project, args.source, args.name)
127
+ model = JSONChatModel(args.base_url, args.model, os.environ.get(args.api_key_env))
128
+ proposal = asyncio.run(model(request))
129
+ source_hash = request["inventory"]["source_hash"]
130
+ result = compiler.generate(
131
+ project,
132
+ args.source,
133
+ name=args.name,
134
+ profile=args.profile,
135
+ proposal=proposal,
136
+ expected_source_hash=source_hash,
137
+ )
138
+ if args.install:
139
+ result["installation"] = hosts.install(project, name=result["name"])
140
+ return result
141
+ if args.command == "validate":
142
+ bundle = compiler.load_bundle(project, args.name)
143
+ return {"valid": True, "name": args.name, **bundle["generation"]}
144
+ if args.command == "doctor":
145
+ result = hosts.doctor(project)
146
+ if args.mcp:
147
+ try:
148
+ from .mcp_server import smoke_test
149
+
150
+ result["mcp_transport"] = asyncio.run(smoke_test(project))
151
+ result["ok"] = result["ok"] and result["mcp_transport"]["ok"]
152
+ except ImportError as exc:
153
+ raise ValidationError(
154
+ "Install skillstate-kit[mcp] for the MCP transport check"
155
+ ) from exc
156
+ except Exception as exc:
157
+ result["mcp_transport"] = {"ok": False, "error": type(exc).__name__}
158
+ result["ok"] = False
159
+ return result
160
+ if args.command == "uninstall":
161
+ return hosts.uninstall(project)
162
+ if args.command == "demo":
163
+ return asyncio.run(run_demo())
164
+ if args.command == "serve":
165
+ try:
166
+ from .mcp_server import serve
167
+
168
+ serve(project)
169
+ except ImportError as exc:
170
+ raise ValidationError("Install skillstate-kit[mcp] to run the MCP server") from exc
171
+ return None
172
+ if args.command == "artifact":
173
+ store = ArtifactStore(project)
174
+ if args.operation == "put":
175
+ path = within(project, args.file)
176
+ if path.stat().st_size > 2_000_000:
177
+ raise ValidationError("Artifact file too large")
178
+ return {"id": store.put(path.read_text(encoding="utf-8"))}
179
+ return store.read(args.id, args.offset, args.length)
180
+ if args.command == "status":
181
+ with service.store() as store:
182
+ return store.list_runs()
183
+ if args.command == "run":
184
+ op = args.operation
185
+ if op == "open":
186
+ return service.open_run(args.name, args.owner, args.id, read(args.observation))
187
+ if op == "context":
188
+ return service.run_context(args.run_id)
189
+ if op == "handoff":
190
+ return service.handoff(args.run_id, args.owner, args.revision, args.to)
191
+ with service.store() as store:
192
+ if op == "update":
193
+ return store.update(
194
+ args.run_id,
195
+ args.owner,
196
+ args.revision,
197
+ read(args.patch),
198
+ read(args.observation),
199
+ done=args.done,
200
+ )
201
+ if op == "events":
202
+ return store.events(args.run_id, args.after)
203
+ if op == "reserve":
204
+ decision = read(args.decision)
205
+ if not isinstance(decision, dict) or set(decision) != {"action", "patch"}:
206
+ raise ValidationError("Native decision must contain exactly action and patch")
207
+ return store.reserve(
208
+ args.run_id, args.owner, args.revision, decision["action"], decision["patch"]
209
+ )
210
+ if op == "unknown":
211
+ return store.mark_unknown(args.operation_id, args.owner, args.reason)
212
+ if op == "result":
213
+ result = read(args.result)
214
+ if not isinstance(result, dict) or set(result) != {"success", "observation"}:
215
+ raise ValidationError("Result must contain exactly success and observation")
216
+ return store.record_result(
217
+ args.operation_id,
218
+ args.owner,
219
+ result["success"],
220
+ result["observation"],
221
+ reconcile=args.reconcile,
222
+ )
223
+ raise ValidationError("Unknown command")
224
+
225
+
226
+ def main(argv: list[str] | None = None) -> int:
227
+ def emit(value, *, stream=None):
228
+ # Strict validation first; ASCII JSON escapes also work in legacy Windows
229
+ # pipes without failing after a mutation has already committed.
230
+ print(json.dumps(json.loads(dumps(value)), ensure_ascii=True), file=stream)
231
+
232
+ args = parser().parse_args(argv)
233
+ try:
234
+ result = dispatch(args)
235
+ if result is not None:
236
+ emit(result)
237
+ return 1 if args.command == "doctor" and not result["ok"] else 0
238
+ except SkillStateError as exc:
239
+ emit({"error": exc.code, "message": str(exc)}, stream=sys.stderr)
240
+ return 2
241
+ except (OSError, UnicodeError) as exc:
242
+ emit({"error": "io_error", "message": str(exc)}, stream=sys.stderr)
243
+ return 3
244
+ except sqlite3.Error as exc:
245
+ emit(
246
+ {
247
+ "error": "storage_error",
248
+ "message": f"SQLite operation failed: {type(exc).__name__}",
249
+ },
250
+ stream=sys.stderr,
251
+ )
252
+ return 3
253
+
254
+
255
+ if __name__ == "__main__":
256
+ raise SystemExit(main())