stml-cli 0.1.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.
stml_cli/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """stml — the partner CLI (024 §Distributing the partner CLI).
2
+
3
+ Pure-Python, stdlib only. ``certifi`` is the sole packaging dependency, used to
4
+ point OpenSSL at a CA bundle so HTTPS to a real backend verifies on framework
5
+ Python builds that ship no CA store (same fix as the monorepo publish scripts).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ # TLS trust: framework/python.org builds have no OpenSSL CA path, so a real
15
+ # https backend fails with "unable to get local issuer certificate". certifi's
16
+ # bundle fixes it. setdefault honours an operator override; no-op for http.
17
+ try: # pragma: no cover - environment dependent
18
+ import certifi
19
+
20
+ os.environ.setdefault("SSL_CERT_FILE", certifi.where())
21
+ except ImportError: # pragma: no cover
22
+ pass
stml_cli/__main__.py ADDED
@@ -0,0 +1,153 @@
1
+ """`stml` CLI entry point — argparse dispatch (024 §CLI surface).
2
+
3
+ stml login | logout | whoami
4
+ stml list [--workspace <ws>]
5
+ stml pull <app-url> [<dir>]
6
+ stml push [<app-url>] <dir>
7
+ stml detach <app-url> (fork: own the full source)
8
+ stml revert <app-url> [--yes] (reset: back to the clean library)
9
+ stml status <app-url>
10
+ stml publish <dir> [--org <slug>]
11
+ stml init <dir> [--name <name>]
12
+
13
+ Auth: STML4_TOKEN overrides everything; otherwise the cached `stml login`
14
+ session is used (and silently refreshed). Backend: --backend or $STML4_BACKEND.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+
22
+ from . import __version__, commands, http
23
+ from .config import DEFAULT_BACKEND
24
+
25
+
26
+ def build_parser() -> argparse.ArgumentParser:
27
+ p = argparse.ArgumentParser(prog="stml", description="The stml partner CLI.")
28
+ p.add_argument("--version", action="version", version=f"stml {__version__}")
29
+ p.add_argument("--backend", default=DEFAULT_BACKEND, help=f"Backend URL (default: {DEFAULT_BACKEND})")
30
+ sub = p.add_subparsers(dest="cmd", required=True)
31
+
32
+ sub.add_parser("login", help="Authenticate in the browser and cache a session").set_defaults(func=commands.cmd_login)
33
+ sub.add_parser("logout", help="Delete the cached session").set_defaults(func=commands.cmd_logout)
34
+ sub.add_parser("whoami", help="Show the resolved user + backend").set_defaults(func=commands.cmd_whoami)
35
+
36
+ ls = sub.add_parser(
37
+ "list",
38
+ help="List apps you can access (name + pull handle)",
39
+ description="List apps you can access, with the exact `stml pull` handle for each.",
40
+ )
41
+ ls.add_argument("--workspace", metavar="<workspace>", help="Filter to one workspace (slug or name)")
42
+ ls.set_defaults(func=commands.cmd_list)
43
+
44
+ # pull / push (an app's source)
45
+ pull = sub.add_parser(
46
+ "pull",
47
+ help="Pull an app's source to a local folder",
48
+ description="Pull an app's source to a local folder.",
49
+ epilog=(
50
+ "Identify the app by its URL — copy it from `stml list` or straight from\n"
51
+ "the browser. A trailing /flows/…, /sessions/… or /settings is ignored, so\n"
52
+ "the URL of a flow or a run resolves to its app just fine.\n\n"
53
+ "examples:\n"
54
+ " stml pull https://app.stml.io/orgs/acme/ws/main/apps/invoice-sync-a1b2\n"
55
+ " stml pull https://app.stml.io/orgs/acme/ws/main/apps/invoice-sync-a1b2/flows/x ./work\n"
56
+ " stml pull invoice-sync-a1b2 # a bare slug also works"
57
+ ),
58
+ formatter_class=argparse.RawDescriptionHelpFormatter,
59
+ )
60
+ pull.add_argument("app", metavar="<app-url>", help="App URL (from `stml list` or the browser); a bare slug also works")
61
+ pull.add_argument("dir", nargs="?", metavar="<dir>", help="Target folder (default: ./<app-slug>)")
62
+ pull.set_defaults(func=commands.cmd_pull)
63
+
64
+ push = sub.add_parser(
65
+ "push",
66
+ help="Push a local folder into the app overlay and deploy",
67
+ description="Push a local folder into the app's overlay and deploy it.",
68
+ epilog=(
69
+ "The <app-url> is optional when pushing a folder created by `stml pull`\n"
70
+ "(it remembers the app in .stml/app.json).\n\n"
71
+ "examples:\n"
72
+ " stml push ./invoice-sync # remembered from pull\n"
73
+ " stml push https://app.stml.io/orgs/acme/ws/main/apps/invoice-sync-a1b2 ./invoice-sync\n"
74
+ " stml push ./invoice-sync --no-deploy"
75
+ ),
76
+ formatter_class=argparse.RawDescriptionHelpFormatter,
77
+ )
78
+ push.add_argument("app", nargs="?", metavar="<app-url>", help="App URL to push to (optional for a pulled folder)")
79
+ push.add_argument("dir", metavar="<dir>", help="Source folder to push")
80
+ push.add_argument("--no-deploy", action="store_true", help="Write the overlay without deploying")
81
+ push.add_argument("--prune", action="store_true", help="Delete overlay paths absent from the folder")
82
+ push.set_defaults(func=commands.cmd_push)
83
+
84
+ # detach / revert / status (story 035 — the app's attachment lifecycle)
85
+ detach = sub.add_parser(
86
+ "detach",
87
+ help="Fork an installed app: full source into the overlay, library pin cleared",
88
+ description=(
89
+ "Fork an installed app into a self-contained one. The full effective source "
90
+ "(base library + your overlay) is materialised into the app's overlay and the "
91
+ "library pin is cleared — after this, `stml pull` returns the whole tree and "
92
+ "`stml publish` can ship it as your own standalone library. The app stops "
93
+ "receiving library updates. Non-destructive; `stml revert` undoes it."
94
+ ),
95
+ )
96
+ detach.add_argument("app", metavar="<app-url>", help="App URL (from `stml list`); a bare slug also works")
97
+ detach.set_defaults(func=commands.cmd_detach)
98
+
99
+ revert = sub.add_parser(
100
+ "revert",
101
+ help="DESTRUCTIVE: discard all customisations, restore the clean library version",
102
+ description=(
103
+ "Permanently delete every overlay customisation (code AND page edits) and restore "
104
+ "the app to its clean library version. A detached app is re-attached to the exact "
105
+ "version it was forked from. Connections, configurations, triggers and run history "
106
+ "are preserved. THERE IS NO UNDO."
107
+ ),
108
+ )
109
+ revert.add_argument("app", metavar="<app-url>", help="App URL (from `stml list`); a bare slug also works")
110
+ revert.add_argument("--yes", action="store_true", help="Skip the interactive confirmation")
111
+ revert.set_defaults(func=commands.cmd_revert)
112
+
113
+ status = sub.add_parser(
114
+ "status",
115
+ help="Show an app's mode (attached / detached / scratch) and provenance",
116
+ )
117
+ status.add_argument("app", metavar="<app-url>", help="App URL (from `stml list`); a bare slug also works")
118
+ status.set_defaults(func=commands.cmd_status)
119
+
120
+ # publish / init (library)
121
+ publish = sub.add_parser("publish", help="Publish a library version from a local folder")
122
+ publish.add_argument("dir")
123
+ publish.add_argument("--org", help="Issuer org slug (overrides [tool.stml].issuer-org)")
124
+ publish.set_defaults(func=commands.cmd_publish)
125
+
126
+ init = sub.add_parser("init", help="Scaffold a new library directory")
127
+ init.add_argument("dir")
128
+ init.add_argument("--name")
129
+ init.set_defaults(func=commands.cmd_init)
130
+
131
+ return p
132
+
133
+
134
+ def main(argv: list[str] | None = None) -> int:
135
+ args = build_parser().parse_args(argv)
136
+ try:
137
+ args.func(args)
138
+ except http.HttpError as e:
139
+ # An unhandled server/transport error (handled ones — e.g. a 409 the
140
+ # pull picker catches — never reach here). Print the clean detail.
141
+ print(f"✗ {e.detail}", file=sys.stderr)
142
+ return 1
143
+ except SystemExit as e:
144
+ # SystemExit carries our user-facing error strings; print and exit non-zero.
145
+ if isinstance(e.code, str):
146
+ print(e.code, file=sys.stderr)
147
+ return 1
148
+ return e.code or 0
149
+ return 0
150
+
151
+
152
+ if __name__ == "__main__":
153
+ raise SystemExit(main())
stml_cli/commands.py ADDED
@@ -0,0 +1,322 @@
1
+ """Subcommand handlers for the `stml` CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+ import tomllib
8
+ import urllib.parse
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ from . import config, http, oauth, paths
13
+ from .packaging import build_tarball
14
+ from .refs import parse_app_ref
15
+
16
+
17
+ def _log(msg: str) -> None:
18
+ print(msg, file=sys.stderr)
19
+
20
+
21
+ def _humanize_delta(seconds: float) -> str:
22
+ """A compact largest-unit relative span, e.g. '42m', '3h', '2d', '15s'."""
23
+ seconds = int(abs(seconds))
24
+ for size, unit in ((86400, "d"), (3600, "h"), (60, "m")):
25
+ if seconds >= size:
26
+ return f"{seconds // size}{unit}"
27
+ return f"{seconds}s"
28
+
29
+
30
+ def _format_expiry(exp: int, now: float | None = None) -> str:
31
+ """Render a JWT ``exp`` (epoch seconds) as local time + relative span."""
32
+ now = time.time() if now is None else now
33
+ local = datetime.fromtimestamp(exp).strftime("%Y-%m-%d %H:%M:%S")
34
+ delta = exp - now
35
+ if delta >= 0:
36
+ return f"{local} (expires in {_humanize_delta(delta)})"
37
+ return f"{local} (EXPIRED {_humanize_delta(delta)} ago)"
38
+
39
+
40
+ # ── auth ────────────────────────────────────────────────────────────────────
41
+
42
+ def cmd_login(args) -> None:
43
+ sess = oauth.login(args.backend)
44
+ claims = http.token_claims(sess["access_token"])
45
+ who = claims.get("email") or claims.get("sub") or "?"
46
+ _log(f"✓ Logged in to {args.backend} as {who}")
47
+
48
+
49
+ def cmd_logout(args) -> None:
50
+ _log("✓ Logged out" if config.clear_session() else "Not logged in.")
51
+
52
+
53
+ def cmd_whoami(args) -> None:
54
+ token = http.resolve_token(args.backend)
55
+ claims = http.token_claims(token)
56
+ _log(f"backend: {args.backend}")
57
+ _log(f"user: {claims.get('email') or claims.get('sub') or '?'}")
58
+ if claims.get("exp"):
59
+ _log(f"token: {_format_expiry(int(claims['exp']))}")
60
+
61
+
62
+ # ── discovery ───────────────────────────────────────────────────────────────
63
+
64
+ def cmd_list(args) -> None:
65
+ token = http.resolve_token(args.backend)
66
+ url = f"{args.backend}/api/apps"
67
+ if args.workspace:
68
+ url += "?" + urllib.parse.urlencode({"workspace": args.workspace})
69
+ apps = http.get_json(url, token).get("apps") or []
70
+ if not apps:
71
+ _log("No apps found.")
72
+ return
73
+ # Display name + mode (035: attached/detached/scratch) + the app URL — the
74
+ # exact handle `stml pull`/`push` accept (the URL's app slug is unique, so
75
+ # it never collides even when a library is installed twice). Rows to stdout
76
+ # so `stml list | grep …` works; header to stderr.
77
+ name_w = max(len(a.get("name") or "") for a in apps)
78
+ mode_w = max((len(a.get("mode") or "") for a in apps), default=4)
79
+ _log(f"{'APP'.ljust(name_w)} {'MODE'.ljust(mode_w)} URL")
80
+ for a in apps:
81
+ print(
82
+ f"{(a.get('name') or '').ljust(name_w)} "
83
+ f"{(a.get('mode') or '').ljust(mode_w)} {a.get('url') or ''}"
84
+ )
85
+
86
+
87
+ # ── app source: pull / push ─────────────────────────────────────────────────
88
+
89
+ def _pick_by_slug(e: http.HttpError, workspace: str | None, app: str) -> None:
90
+ """Turn a 409 'matches multiple apps' into a slug picker; re-raise else."""
91
+ if e.code != 409:
92
+ raise SystemExit(e.detail)
93
+ marker = "exact slug:"
94
+ slugs = (
95
+ [s.strip() for s in e.detail.split(marker, 1)[1].split(",") if s.strip()]
96
+ if marker in e.detail else []
97
+ )
98
+ prefix = f"{workspace}/" if workspace else ""
99
+ lines = [f"'{app}' matches multiple apps — pick one by slug:"]
100
+ lines += [f" stml pull {prefix}{s}" for s in slugs]
101
+ if not slugs:
102
+ lines.append(" run `stml list` to see the slugs")
103
+ raise SystemExit("\n".join(lines))
104
+
105
+
106
+ def cmd_pull(args) -> None:
107
+ workspace, app = parse_app_ref(args.app)
108
+ token = http.resolve_token(args.backend)
109
+
110
+ query = {"app": app}
111
+ if workspace:
112
+ query["workspace"] = workspace
113
+ url = f"{args.backend}/api/app-source?" + urllib.parse.urlencode(query)
114
+ try:
115
+ result = http.get_json(url, token)
116
+ except http.HttpError as e:
117
+ _pick_by_slug(e, workspace, app)
118
+ files = result.get("files") or []
119
+
120
+ dest = Path(args.dir).resolve() if args.dir else Path.cwd() / app
121
+ dest.mkdir(parents=True, exist_ok=True)
122
+ written = paths.write_tree(dest, files)
123
+
124
+ # Record the source coordinate so a later push can be run from the folder.
125
+ _write_link(dest, args.backend, workspace, app)
126
+ _log(f"✓ Pulled {len(written)} file(s) from {result.get('app', app)} → {dest}")
127
+
128
+
129
+ def cmd_push(args) -> None:
130
+ src = Path(args.dir).resolve()
131
+ if not src.is_dir():
132
+ raise SystemExit(f"{src} is not a directory")
133
+
134
+ link = _read_link(src)
135
+ if args.app:
136
+ workspace, app = parse_app_ref(args.app)
137
+ elif link:
138
+ workspace, app = link.get("workspace"), link["app"]
139
+ else:
140
+ raise SystemExit("Pass an app URL (or push a folder created by `stml pull`).")
141
+
142
+ files = paths.read_tree(src)
143
+ if not files:
144
+ raise SystemExit(f"No source files found under {src}")
145
+
146
+ token = http.resolve_token(args.backend)
147
+ body = {
148
+ "app": app,
149
+ "workspace": workspace,
150
+ "files": files,
151
+ "deploy": not args.no_deploy,
152
+ "prune": args.prune,
153
+ }
154
+ try:
155
+ result = http.post_json(f"{args.backend}/api/app-source", body, token)
156
+ except http.HttpError as e:
157
+ _pick_by_slug(e, workspace, app)
158
+ verb = "pushed" if args.no_deploy else "pushed + deployed"
159
+ _log(f"✓ {verb} {len(files)} file(s) to {app} (written={result.get('written', '?')})")
160
+
161
+
162
+ # ── detach / revert (story 035) ─────────────────────────────────────────────
163
+
164
+ def _detach_revert_url(backend: str, ref: str, action: str) -> str:
165
+ """The action endpoint for an app reference. The backend accepts a slug or
166
+ a display name in the path; URL-style refs are reduced to their slug via
167
+ the same parser pull/push use."""
168
+ _workspace, app = parse_app_ref(ref)
169
+ return f"{backend}/api/apps/{urllib.parse.quote(app, safe='')}/{action}"
170
+
171
+
172
+ def cmd_detach(args) -> None:
173
+ token = http.resolve_token(args.backend)
174
+ result = http.post_json(_detach_revert_url(args.backend, args.app, "detach"), {}, token)
175
+ if result.get("status") == "noop":
176
+ _log(result.get("message") or "Nothing to do.")
177
+ return
178
+ _log(f"✓ Detached from {result.get('detached_from', '?')} — "
179
+ f"{result.get('files_materialized', '?')} file(s) materialised; the app is self-contained.")
180
+ for path in result.get("skipped_binary") or []:
181
+ _log(f" ⚠ skipped binary file: {path}")
182
+ if result.get("deploy_error"):
183
+ _log(f" ⚠ deploy failed (detach itself succeeded): {result['deploy_error']}")
184
+ _log(" `stml pull` now returns the full tree; `stml revert` undoes this.")
185
+
186
+
187
+ def cmd_revert(args) -> None:
188
+ token = http.resolve_token(args.backend)
189
+ if not args.yes:
190
+ _log("This PERMANENTLY deletes every customisation of this app (code and page "
191
+ "edits) and restores the clean library version. There is no undo.")
192
+ _log("Connections, configurations, triggers and run history are preserved.")
193
+ answer = input("Type the word revert to continue: ").strip().lower()
194
+ if answer != "revert":
195
+ raise SystemExit("Aborted — nothing changed.")
196
+ result = http.post_json(_detach_revert_url(args.backend, args.app, "revert"), {}, token)
197
+ if result.get("status") == "noop":
198
+ _log(result.get("message") or "Nothing to do.")
199
+ return
200
+ _log(f"✓ Reverted to {result.get('restored_version', '?')} — "
201
+ f"{result.get('overlay_files_removed', '?')} overlay file(s) removed.")
202
+ for f in result.get("removed_flows") or []:
203
+ _log(f" ⚠ removed flow whose source left with the overlay: {f.get('name')}")
204
+ if result.get("version_yanked"):
205
+ _log(" ⚠ the restored version is yanked — a newer version exists (App Store → Update).")
206
+ if result.get("deploy_error"):
207
+ _log(f" ⚠ deploy failed (revert itself succeeded): {result['deploy_error']}")
208
+
209
+
210
+ def cmd_status(args) -> None:
211
+ """Mode + provenance for one app, resolved via `stml list`'s endpoint."""
212
+ token = http.resolve_token(args.backend)
213
+ _workspace, app = parse_app_ref(args.app)
214
+ apps = http.get_json(f"{args.backend}/api/apps", token).get("apps") or []
215
+ row = next((a for a in apps if a.get("slug") == app or a.get("name") == app), None)
216
+ if row is None:
217
+ raise SystemExit(f"App {app!r} not found (see `stml list`).")
218
+ _log(f"app: {row.get('name')} ({row.get('slug')})")
219
+ _log(f"mode: {row.get('mode')}")
220
+ _log(f"url: {row.get('url')}")
221
+
222
+
223
+ # ── publish / init (library, ported from stml_lib.py) ───────────────────────
224
+
225
+ def cmd_publish(args) -> None:
226
+ lib_dir = Path(args.dir).resolve()
227
+ manifest_path = lib_dir / "pyproject.toml"
228
+ if not manifest_path.exists():
229
+ raise SystemExit(f"{manifest_path} not found")
230
+ manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
231
+ project = manifest.get("project") or {}
232
+ name, version = project.get("name"), project.get("version")
233
+ if not name or not version:
234
+ raise SystemExit("pyproject.toml missing project.name or project.version")
235
+
236
+ stml_cfg = (manifest.get("tool") or {}).get("stml") or {}
237
+ org_slug = args.org or stml_cfg.get("issuer-org")
238
+ if not org_slug:
239
+ raise SystemExit("Pass --org or set [tool.stml].issuer-org in pyproject.toml")
240
+
241
+ token = http.resolve_token(args.backend)
242
+
243
+ # Ensure the library exists (409 = already there, which is fine).
244
+ try:
245
+ http.post_json(f"{args.backend}/api/libraries", {"org_slug": org_slug, "name": name}, token)
246
+ _log(f"Created library {org_slug}/{name}")
247
+ except http.HttpError as e:
248
+ if e.code == 409 or "already exists" in (e.detail or ""):
249
+ _log(f"Library {org_slug}/{name} exists — uploading new version")
250
+ else:
251
+ raise SystemExit(e.detail)
252
+
253
+ tarball = build_tarball(lib_dir)
254
+ _log(f"Built tarball: {len(tarball)} bytes")
255
+ result = http.post_multipart(
256
+ f"{args.backend}/api/libraries/{org_slug}/{name}/versions",
257
+ fields={"expected_version": version},
258
+ file_field="tarball",
259
+ filename=f"{name}-{version}.tar.gz",
260
+ file_bytes=tarball,
261
+ token=token,
262
+ )
263
+ sha = (result.get("tarball_sha256") or "")[:12]
264
+ size_kb = (result.get("size_bytes") or 0) / 1024
265
+ _log(f"✓ Published {org_slug}/{name}@{version} ({size_kb:.1f} KB, sha={sha}…)")
266
+
267
+
268
+ def cmd_init(args) -> None:
269
+ target = Path(args.dir).resolve()
270
+ if target.exists() and any(target.iterdir()):
271
+ raise SystemExit(f"{target} exists and is not empty")
272
+ target.mkdir(parents=True, exist_ok=True)
273
+ name = args.name or target.name
274
+ (target / "pyproject.toml").write_text(
275
+ f'''[project]
276
+ name = "{name}"
277
+ version = "0.1.0"
278
+ description = "A reusable stml library."
279
+ requires-python = ">=3.11"
280
+ dependencies = []
281
+
282
+ [tool.stml]
283
+ requires-runtime = ">=0.1"
284
+ issuer-org = "" # set to your org slug
285
+ exports-flows = []
286
+ depends-on = []
287
+ ''',
288
+ encoding="utf-8",
289
+ )
290
+ pkg = target / name.replace("-", "_")
291
+ pkg.mkdir()
292
+ (pkg / "__init__.py").write_text('"""Library entry point."""\n', encoding="utf-8")
293
+ (target / "README.md").write_text(f"# {name}\n", encoding="utf-8")
294
+ _log(f"✓ Scaffolded {target}")
295
+
296
+
297
+ # ── pull/push folder link (.stml/app.json) ──────────────────────────────────
298
+
299
+ _LINK = Path(".stml") / "app.json"
300
+
301
+
302
+ def _write_link(dest: Path, backend: str, workspace: str | None, app: str) -> None:
303
+ import json
304
+
305
+ link_path = dest / _LINK
306
+ link_path.parent.mkdir(parents=True, exist_ok=True)
307
+ link_path.write_text(
308
+ json.dumps({"backend": backend, "workspace": workspace, "app": app}, indent=2),
309
+ encoding="utf-8",
310
+ )
311
+
312
+
313
+ def _read_link(src: Path) -> dict | None:
314
+ import json
315
+
316
+ p = src / _LINK
317
+ if not p.exists():
318
+ return None
319
+ try:
320
+ return json.loads(p.read_text(encoding="utf-8"))
321
+ except (json.JSONDecodeError, OSError):
322
+ return None
stml_cli/config.py ADDED
@@ -0,0 +1,64 @@
1
+ """Per-OS config dir + the cached login session (024 §OS & runtime support §4).
2
+
3
+ The refresh token lives here between commands; access tokens are minted per
4
+ call and never persisted. The file is written user-only where the OS supports
5
+ it (``chmod`` is a no-op on Windows, which relies on the per-user profile dir).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ DEFAULT_BACKEND = os.environ.get("STML4_BACKEND", "http://localhost:8010")
16
+
17
+ _SESSION_FILE = "session.json"
18
+
19
+
20
+ def config_dir() -> Path:
21
+ """OS-appropriate config directory for stml (created on demand)."""
22
+ if sys.platform == "darwin":
23
+ base = Path.home() / "Library" / "Application Support"
24
+ elif os.name == "nt":
25
+ base = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
26
+ else: # Linux / other POSIX
27
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
28
+ return base / "stml"
29
+
30
+
31
+ def _session_path() -> Path:
32
+ return config_dir() / _SESSION_FILE
33
+
34
+
35
+ def read_session() -> dict | None:
36
+ """Return the cached session dict, or None if not logged in."""
37
+ p = _session_path()
38
+ if not p.exists():
39
+ return None
40
+ try:
41
+ return json.loads(p.read_text(encoding="utf-8"))
42
+ except (json.JSONDecodeError, OSError):
43
+ return None
44
+
45
+
46
+ def write_session(session: dict) -> None:
47
+ """Persist the session dict with user-only perms where supported."""
48
+ d = config_dir()
49
+ d.mkdir(parents=True, exist_ok=True)
50
+ p = _session_path()
51
+ p.write_text(json.dumps(session, indent=2), encoding="utf-8")
52
+ try: # best-effort; POSIX only
53
+ os.chmod(p, 0o600)
54
+ except OSError: # pragma: no cover
55
+ pass
56
+
57
+
58
+ def clear_session() -> bool:
59
+ """Delete the cached session. Returns True if one was present."""
60
+ p = _session_path()
61
+ if p.exists():
62
+ p.unlink()
63
+ return True
64
+ return False
stml_cli/http.py ADDED
@@ -0,0 +1,164 @@
1
+ """Tiny stdlib HTTP client + token resolution.
2
+
3
+ Token precedence (024 §The partner CLI login flow):
4
+ 1. ``STML4_TOKEN`` env — used verbatim (monorepo `npm run token`, CI).
5
+ 2. the cached login session — its access token, silently refreshed when stale.
6
+ 3. otherwise: a clear "run stml login" error.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import uuid
18
+ from typing import Any
19
+
20
+ from . import config
21
+
22
+ CLIENT_ID = "stml4-cli"
23
+
24
+
25
+ class HttpError(Exception):
26
+ """A non-2xx (or transport) failure, carrying the status code + a clean
27
+ detail string. Callers can branch on ``.code`` (e.g. 409 → disambiguate);
28
+ the top-level handler prints ``.detail`` for everything else.
29
+ """
30
+
31
+ def __init__(self, code: int, detail: str):
32
+ super().__init__(detail)
33
+ self.code = code
34
+ self.detail = detail
35
+
36
+
37
+ # ── low-level requests ──────────────────────────────────────────────────────
38
+
39
+ def _open(req: urllib.request.Request) -> dict:
40
+ try:
41
+ with urllib.request.urlopen(req) as resp:
42
+ body = resp.read().decode("utf-8")
43
+ return json.loads(body) if body else {}
44
+ except urllib.error.HTTPError as e:
45
+ raw = e.read().decode("utf-8", "replace")
46
+ detail = raw
47
+ try: # FastAPI errors are {"detail": "..."} — surface the message, not JSON
48
+ parsed = json.loads(raw)
49
+ if isinstance(parsed, dict) and "detail" in parsed:
50
+ detail = str(parsed["detail"])
51
+ except (ValueError, TypeError):
52
+ pass
53
+ raise HttpError(e.code, detail)
54
+ except urllib.error.URLError as e: # network / DNS / TLS
55
+ raise HttpError(0, f"cannot reach {req.full_url}: {e.reason}")
56
+
57
+
58
+ def get_json(url: str, token: str) -> dict:
59
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="GET")
60
+ return _open(req)
61
+
62
+
63
+ def post_json(url: str, body: dict, token: str | None = None) -> dict:
64
+ headers = {"Content-Type": "application/json"}
65
+ if token:
66
+ headers["Authorization"] = f"Bearer {token}"
67
+ req = urllib.request.Request(
68
+ url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST"
69
+ )
70
+ return _open(req)
71
+
72
+
73
+ def post_form(url: str, fields: dict[str, str]) -> dict:
74
+ """application/x-www-form-urlencoded POST (the OAuth token endpoint)."""
75
+ data = urllib.parse.urlencode(fields).encode("utf-8")
76
+ req = urllib.request.Request(
77
+ url, data=data,
78
+ headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST",
79
+ )
80
+ return _open(req)
81
+
82
+
83
+ def post_multipart(
84
+ url: str, fields: dict[str, str], file_field: str, filename: str,
85
+ file_bytes: bytes, token: str,
86
+ ) -> dict:
87
+ boundary = f"----STML4-{uuid.uuid4().hex}"
88
+ body = bytearray()
89
+ for name, value in fields.items():
90
+ body += f"--{boundary}\r\n".encode()
91
+ body += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
92
+ body += value.encode("utf-8") + b"\r\n"
93
+ body += f"--{boundary}\r\n".encode()
94
+ body += (
95
+ f'Content-Disposition: form-data; name="{file_field}"; filename="{filename}"\r\n'
96
+ f"Content-Type: application/gzip\r\n\r\n"
97
+ ).encode()
98
+ body += file_bytes + b"\r\n"
99
+ body += f"--{boundary}--\r\n".encode()
100
+
101
+ req = urllib.request.Request(
102
+ url, data=bytes(body),
103
+ headers={
104
+ "Authorization": f"Bearer {token}",
105
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
106
+ },
107
+ method="POST",
108
+ )
109
+ return _open(req)
110
+
111
+
112
+ # ── token resolution ────────────────────────────────────────────────────────
113
+
114
+ def resolve_token(backend: str) -> str:
115
+ """Return a usable access token, refreshing the cached session if stale."""
116
+ import os
117
+
118
+ override = os.environ.get("STML4_TOKEN")
119
+ if override:
120
+ return override.strip()
121
+
122
+ sess = config.read_session()
123
+ if not sess or not sess.get("refresh_token"):
124
+ raise SystemExit("Not logged in. Run: stml login (or set STML4_TOKEN)")
125
+
126
+ now = int(time.time())
127
+ if not sess.get("expires_at") or int(sess["expires_at"]) - now < 60:
128
+ sess = refresh_session(backend, sess)
129
+ return sess["access_token"]
130
+
131
+
132
+ def refresh_session(backend: str, sess: dict) -> dict:
133
+ """Exchange the refresh token for a fresh access token; re-cache."""
134
+ try:
135
+ tok = post_form(
136
+ f"{backend}/mcp/oauth/token",
137
+ {"grant_type": "refresh_token", "refresh_token": sess["refresh_token"], "client_id": CLIENT_ID},
138
+ )
139
+ except HttpError:
140
+ raise SystemExit("Session expired. Run: stml login")
141
+ return _store_tokens(backend, tok)
142
+
143
+
144
+ def _store_tokens(backend: str, tok: dict) -> dict:
145
+ now = int(time.time())
146
+ expires_at = tok.get("expires_at") or (now + int(tok.get("expires_in") or 3600))
147
+ sess = {
148
+ "backend": backend,
149
+ "access_token": tok["access_token"],
150
+ "refresh_token": tok["refresh_token"],
151
+ "expires_at": int(expires_at),
152
+ }
153
+ config.write_session(sess)
154
+ return sess
155
+
156
+
157
+ def token_claims(token: str) -> dict[str, Any]:
158
+ """Best-effort decode of a JWT payload (NOT verified — display only)."""
159
+ try:
160
+ payload = token.split(".")[1]
161
+ payload += "=" * (-len(payload) % 4)
162
+ return json.loads(base64.urlsafe_b64decode(payload))
163
+ except Exception:
164
+ return {}
stml_cli/oauth.py ADDED
@@ -0,0 +1,90 @@
1
+ """`stml login` — the browser OAuth loopback flow (024 §The partner CLI login).
2
+
3
+ Standard PKCE authorization-code flow against the platform's existing
4
+ ``/mcp/oauth/{authorize,token}`` endpoints (the same ones `npm run libs:login`
5
+ drives), with a one-shot loopback server catching the redirect. No password is
6
+ ever handled by the CLI; the refresh token is cached, the access token is not.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import secrets
14
+ import sys
15
+ import urllib.parse
16
+ import webbrowser
17
+ from http.server import BaseHTTPRequestHandler, HTTPServer
18
+
19
+ from . import http
20
+ from .http import CLIENT_ID
21
+
22
+ _DONE_PAGE = (
23
+ b"<!doctype html><html><body style='font-family:sans-serif;padding:3rem'>"
24
+ b"<h2>stml login complete</h2><p>You can close this tab and return to the terminal.</p>"
25
+ b"</body></html>"
26
+ )
27
+
28
+
29
+ def _pkce_pair() -> tuple[str, str]:
30
+ verifier = secrets.token_urlsafe(64)
31
+ challenge = base64.urlsafe_b64encode(
32
+ hashlib.sha256(verifier.encode("ascii")).digest()
33
+ ).rstrip(b"=").decode("ascii")
34
+ return verifier, challenge
35
+
36
+
37
+ def login(backend: str) -> dict:
38
+ """Run the browser flow and cache the resulting session. Returns it."""
39
+ verifier, challenge = _pkce_pair()
40
+ state = secrets.token_urlsafe(16)
41
+ captured: dict[str, str | None] = {}
42
+
43
+ class _Handler(BaseHTTPRequestHandler):
44
+ def do_GET(self): # noqa: N802 (stdlib signature)
45
+ params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
46
+ captured["code"] = (params.get("code") or [None])[0]
47
+ captured["state"] = (params.get("state") or [None])[0]
48
+ self.send_response(200)
49
+ self.send_header("Content-Type", "text/html")
50
+ self.end_headers()
51
+ self.wfile.write(_DONE_PAGE)
52
+
53
+ def log_message(self, *_args): # silence the default stderr logging
54
+ pass
55
+
56
+ server = HTTPServer(("127.0.0.1", 0), _Handler)
57
+ redirect_uri = f"http://127.0.0.1:{server.server_address[1]}/callback"
58
+ authorize_url = f"{backend}/mcp/oauth/authorize?" + urllib.parse.urlencode({
59
+ "response_type": "code",
60
+ "client_id": CLIENT_ID,
61
+ "redirect_uri": redirect_uri,
62
+ "state": state,
63
+ "code_challenge": challenge,
64
+ "code_challenge_method": "S256",
65
+ "scope": "mcp",
66
+ })
67
+
68
+ print("Opening your browser to sign in…", file=sys.stderr)
69
+ print(f"If it doesn't open, visit:\n {authorize_url}\n", file=sys.stderr)
70
+ try:
71
+ webbrowser.open(authorize_url)
72
+ except Exception: # headless / no browser — the printed URL is the fallback
73
+ pass
74
+
75
+ server.handle_request() # blocks until the single callback arrives
76
+ server.server_close()
77
+
78
+ if not captured.get("code"):
79
+ raise SystemExit("Login failed: no authorization code was returned.")
80
+ if captured.get("state") != state:
81
+ raise SystemExit("Login failed: state mismatch (possible CSRF); aborted.")
82
+
83
+ tok = http.post_form(f"{backend}/mcp/oauth/token", {
84
+ "grant_type": "authorization_code",
85
+ "code": captured["code"],
86
+ "code_verifier": verifier,
87
+ "redirect_uri": redirect_uri,
88
+ "client_id": CLIENT_ID,
89
+ })
90
+ return http._store_tokens(backend, tok)
stml_cli/packaging.py ADDED
@@ -0,0 +1,42 @@
1
+ """Publish-layout tarball builder (ported from scripts/stml_lib.py).
2
+
3
+ One directory → a ``<dir-name>/…`` gzip tarball, excluding local dev junk. The
4
+ platform publish endpoint reads the manifest and explodes ``src/``; this only
5
+ packages the bytes. Kept byte-compatible with the monorepo builder so a lib
6
+ published by either path lands identically.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import tarfile
13
+ from pathlib import Path
14
+
15
+ EXCLUDE_DIRS = {
16
+ ".git", ".venv", "venv", "__pycache__", "node_modules", ".pytest_cache",
17
+ "dist", "build", ".mypy_cache", ".ruff_cache",
18
+ # Unit tests live in a top-level tests/ per lib (pytest testpaths); test-
19
+ # named files inside packages are intentional runnable flows, so dir-only.
20
+ "tests",
21
+ }
22
+ EXCLUDE_DIR_SUFFIXES = (".egg-info",)
23
+ EXCLUDE_FILES = {"uv.lock", "poetry.lock", "Pipfile.lock", ".DS_Store"}
24
+ EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
25
+
26
+
27
+ def build_tarball(lib_dir: Path) -> bytes:
28
+ buf = io.BytesIO()
29
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
30
+ for path in sorted(lib_dir.rglob("*")):
31
+ parts = path.relative_to(lib_dir).parts
32
+ if any(part in EXCLUDE_DIRS for part in parts):
33
+ continue
34
+ if any(part.endswith(EXCLUDE_DIR_SUFFIXES) for part in parts):
35
+ continue
36
+ if path.name in EXCLUDE_FILES or path.suffix in EXCLUDE_SUFFIXES:
37
+ continue
38
+ if not path.is_file():
39
+ continue
40
+ arcname = f"{lib_dir.name}/{path.relative_to(lib_dir).as_posix()}"
41
+ tar.add(path, arcname=arcname)
42
+ return buf.getvalue()
stml_cli/paths.py ADDED
@@ -0,0 +1,70 @@
1
+ """Cross-platform overlay ↔ local-folder mapping (024 §OS & runtime support).
2
+
3
+ The overlay keys the platform stores are always POSIX (``flows/x.py``). "Runs on
4
+ Windows" is only real if the CLI:
5
+ 1. maps a POSIX key to a NATIVE path when writing to disk, and back to a POSIX
6
+ key (never backslashes) when reading — via ``PurePosixPath`` on the wire,
7
+ ``Path`` on disk;
8
+ 2. reads/writes UTF-8 and preserves ``\n`` exactly, so a pulled file survives
9
+ an untouched push byte-for-byte (no CRLF injection on Windows).
10
+
11
+ These two functions are the whole contract; they are unit-tested in isolation so
12
+ the Windows guarantee doesn't rest on a live round-trip.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path, PurePosixPath
18
+
19
+ # Local-only artifacts that must never be pushed into the overlay.
20
+ _EXCLUDE_DIRS = {
21
+ ".git", "__pycache__", ".venv", "venv", ".stml",
22
+ "node_modules", ".mypy_cache", ".ruff_cache", ".pytest_cache",
23
+ }
24
+ _EXCLUDE_NAMES = {".DS_Store"}
25
+ _EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
26
+
27
+
28
+ def to_posix_key(rel: Path) -> str:
29
+ """A native relative path → the POSIX overlay key (forward slashes)."""
30
+ return PurePosixPath(*rel.parts).as_posix()
31
+
32
+
33
+ def key_to_native(key: str) -> Path:
34
+ """A POSIX overlay key → a native relative Path for this OS."""
35
+ return Path(*PurePosixPath(key).parts)
36
+
37
+
38
+ def read_tree(root: Path) -> list[dict]:
39
+ """Read a local folder into ``[{path, content}]`` with POSIX keys.
40
+
41
+ ``read_text`` (universal newlines) collapses any CRLF to ``\n``, so the
42
+ content pushed is always LF-normalised regardless of the author's OS.
43
+ """
44
+ out: list[dict] = []
45
+ for p in sorted(root.rglob("*")):
46
+ rel = p.relative_to(root)
47
+ if any(part in _EXCLUDE_DIRS for part in rel.parts):
48
+ continue
49
+ if p.name in _EXCLUDE_NAMES or p.suffix in _EXCLUDE_SUFFIXES:
50
+ continue
51
+ if not p.is_file():
52
+ continue
53
+ out.append({"path": to_posix_key(rel), "content": p.read_text(encoding="utf-8")})
54
+ return out
55
+
56
+
57
+ def write_tree(root: Path, files: list[dict]) -> list[str]:
58
+ """Write ``[{path, content}]`` (POSIX keys) into a local folder.
59
+
60
+ ``newline=""`` disables newline translation so ``\n`` is written verbatim —
61
+ a pulled file is not silently rewritten to CRLF on Windows.
62
+ """
63
+ written: list[str] = []
64
+ for f in files:
65
+ key = f["path"]
66
+ dest = root / key_to_native(key)
67
+ dest.parent.mkdir(parents=True, exist_ok=True)
68
+ dest.write_text(f.get("content") or "", encoding="utf-8", newline="")
69
+ written.append(key)
70
+ return written
stml_cli/refs.py ADDED
@@ -0,0 +1,47 @@
1
+ """Resolve an app reference to (workspace, app).
2
+
3
+ The canonical handle is an **app URL** — exactly what `stml list` prints and what
4
+ the browser shows. Parsing is deliberately forgiving (most-specific first):
5
+
6
+ - a full app URL with ANY trailing path — /flows/…, /sessions/…, /settings are
7
+ ignored, so pasting the URL of a flow or a run still resolves to its app:
8
+ https://app.stml.io/orgs/acme/ws/main/apps/invoice-sync-a1b2/flows/x/5
9
+ - a bare path: /orgs/acme/ws/main/apps/invoice-sync-a1b2
10
+ - <workspace>/<app> (legacy, still accepted)
11
+ - <app> (a bare slug or display name)
12
+
13
+ For a URL/path we return the app **slug** (unique) plus the workspace slug for
14
+ context; for the bare forms we return whatever was given (slug or name), and the
15
+ backend resolves + disambiguates it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from urllib.parse import urlparse
21
+
22
+
23
+ def parse_app_ref(ref: str) -> tuple[str | None, str]:
24
+ ref = (ref or "").strip()
25
+ if not ref:
26
+ raise SystemExit("No app given. Pass an app URL (see `stml list`).")
27
+
28
+ # A URL or a path carrying the app route → pull the slug out, drop the rest.
29
+ path = urlparse(ref).path if "://" in ref else ref
30
+ segs = [s for s in path.split("/") if s]
31
+ if "apps" in segs:
32
+ i = segs.index("apps")
33
+ if i + 1 >= len(segs):
34
+ raise SystemExit(f"URL has no app slug after /apps/: {ref}")
35
+ app = segs[i + 1] # trailing /flows/…, /sessions/…, /settings ignored
36
+ workspace = None
37
+ if "ws" in segs:
38
+ j = segs.index("ws")
39
+ if j + 1 < len(segs):
40
+ workspace = segs[j + 1]
41
+ return workspace, app
42
+
43
+ # Not a URL: <workspace>/<app>, or a bare slug/name.
44
+ if "/" in ref:
45
+ workspace, app = ref.split("/", 1)
46
+ return (workspace or None), app
47
+ return None, ref
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: stml-cli
3
+ Version: 0.1.0
4
+ Summary: The stml partner CLI — pull, edit, push, and publish stml apps and libraries.
5
+ Author: stml
6
+ Project-URL: Homepage, https://stml.io
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: certifi
10
+
11
+ # stml — the partner CLI
12
+
13
+ Pull an app you built on the platform down to a local folder, edit it with your
14
+ own editor and git, push it back to test, and publish a finished version to the
15
+ App Store — without cloning the stml4 monorepo.
16
+
17
+ Spec: [`docs/user-stories/feature/024-app-authoring-platform.md`](../docs/user-stories/feature/024-app-authoring-platform.md).
18
+
19
+ ## Requirements
20
+
21
+ - **Python 3.11 or newer** on your PATH. That's the only hard requirement.
22
+ - macOS: `brew install python@3.12` (or python.org). **Not** the system
23
+ `/usr/bin/python3` — it's often 3.9, which is too old.
24
+ - Linux: your distro's Python 3.11+, or `pipx`/`pyenv`/`uv`.
25
+ - Windows 10/11: the python.org installer (gives you the `py` launcher).
26
+
27
+ ## Install
28
+
29
+ | Method | Command |
30
+ |---|---|
31
+ | **pipx** (recommended) | `pipx install stml-cli` |
32
+ | pip (in a venv) | `pip install stml-cli` |
33
+ | single file (no installer) | `curl -fsSL <backend>/cli/stml -o stml && chmod +x stml` |
34
+
35
+ Runs on **macOS, Linux, and Windows** — it's pure-Python and touches nothing
36
+ platform-specific beyond the filesystem and HTTPS.
37
+
38
+ ## Quickstart
39
+
40
+ ```sh
41
+ # Point at your backend (default: http://localhost:8010)
42
+ export STML4_BACKEND=https://api.stml.io
43
+
44
+ # 1. Sign in — opens your browser; approve, and a session is cached locally.
45
+ stml login
46
+ stml whoami
47
+
48
+ # 2. See your apps and their URLs (the handle you pull/push by).
49
+ stml list
50
+
51
+ # 3. Pull an app's source into a folder — identify it by its URL (copy from
52
+ # `stml list` or the browser; a trailing /flows/… or /sessions/… is fine).
53
+ stml pull https://app.stml.io/orgs/acme/ws/main/apps/my-app-a1b2 ./my-app
54
+
55
+ # 3. Edit with your own tools, commit to git, whatever.
56
+
57
+ # 4. Push it back — checks, writes the overlay, and deploys in one step.
58
+ stml push ./my-app # app remembered from pull
59
+ # or: stml push https://app.stml.io/orgs/acme/ws/main/apps/my-app-a1b2 ./my-app
60
+
61
+ # 5. When ready, publish a version to the App Store.
62
+ # (bump project.version in pyproject.toml first; set [tool.stml].issuer-org)
63
+ stml publish ./my-app
64
+ ```
65
+
66
+ ## Authentication
67
+
68
+ `stml login` runs the platform's browser OAuth flow (the same one the monorepo's
69
+ `npm run libs:login` uses): you authenticate in the browser, approve, and a
70
+ **refresh token** is cached under your OS config dir (`~/Library/Application
71
+ Support/stml`, `$XDG_CONFIG_HOME/stml`, or `%APPDATA%\stml`). Access tokens are
72
+ minted per-command and never written to disk.
73
+
74
+ `STML4_TOKEN`, if set, overrides the cache verbatim — how CI and the monorepo
75
+ (`STML4_TOKEN=$(npm run --silent token)`) authenticate.
76
+
77
+ ## Commands
78
+
79
+ | Command | What |
80
+ |---|---|
81
+ | `stml login` / `logout` / `whoami` | session auth |
82
+ | `stml list [--workspace <ws>]` | list your apps with their app URLs |
83
+ | `stml pull <app-url> [<dir>]` | pull overlay source to a folder (bare slug also works) |
84
+ | `stml push [<app-url>] <dir>` | push a folder to the overlay **and deploy** (`--no-deploy`, `--prune`) |
85
+ | `stml publish <dir> [--org <slug>]` | publish a library version from a folder |
86
+ | `stml init <dir> [--name <name>]` | scaffold a new library directory |
@@ -0,0 +1,14 @@
1
+ stml_cli/__init__.py,sha256=24NhWGqLjLcIepCQTVzJ4XpcXH4m2PKI97iwaZcqsk8,789
2
+ stml_cli/__main__.py,sha256=6XpV-fU9IF_mcWaEw-8_FCNV1RlAVnQNFW87vSx8CUE,7229
3
+ stml_cli/commands.py,sha256=oNZg1RjQYYRg3ntOypzz9ZiKDwhBWEeW6f5LaPulD0A,12657
4
+ stml_cli/config.py,sha256=va3K5zTLylAj53Pg7fuzA_obXUeXkER6WmxtCJRY-vM,1934
5
+ stml_cli/http.py,sha256=WMb9QsJNNLoHfa0RCqMoK1lUh8cB8yl8WrHT1IeWdKA,5676
6
+ stml_cli/oauth.py,sha256=3nSc6B4uFibfNMBlAVPKNCy2H_f5VDc5yV584VluL-E,3258
7
+ stml_cli/packaging.py,sha256=ggOjdsEM0kQu0f5IEHuTaofr6clupqXK8GyonwJbfEI,1626
8
+ stml_cli/paths.py,sha256=C6OKhiTI_H6AU9g4tQnR3nL0WPncsau_JCaQXs3xRtI,2619
9
+ stml_cli/refs.py,sha256=Bgp305FqtqpUeDORth-lKH4KoSGBosyZDstEXoCiBXA,1862
10
+ stml_cli-0.1.0.dist-info/METADATA,sha256=GqdY2kIEwfnW5xDUTHB8-OLDnSYmiA5R1Ik6Y6xupA8,3379
11
+ stml_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ stml_cli-0.1.0.dist-info/entry_points.txt,sha256=i3Zxa4MVRZREo5hm7lF82PQpHctVP9gowtssxW1WKN4,48
13
+ stml_cli-0.1.0.dist-info/top_level.txt,sha256=cuShv3nGzgfIZZAT7Xq_tOLE3dVUft3KW-LKT-b4hEc,9
14
+ stml_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stml = stml_cli.__main__:main
@@ -0,0 +1 @@
1
+ stml_cli