elan-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.
elan_cli/tenant.py ADDED
@@ -0,0 +1,59 @@
1
+ """`elan tenant` — creating a customer, and listing the ones that exist.
2
+
3
+ PLATFORM STAFF ONLY, AND THE CLI DOES NOT CHECK THAT. The service does, and
4
+ saying no in two places means two places to disagree — the one that matters is
5
+ the one holding the Zitadel credential. What this does is make the refusal
6
+ legible: a 403 from the API is printed as the sentence the service wrote, which
7
+ explains that a tenant cannot create another tenant.
8
+
9
+ THIS IS NOT `elan init`. That scaffolds a repo for a customer who already
10
+ exists; this creates the customer. They are adjacent in a developer's day and
11
+ completely different in what they touch, which is why they are separate
12
+ commands rather than flags of one.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from elan_cli.client import Api
18
+
19
+
20
+ def create(api: Api, slug: str, display_name: str = "") -> int:
21
+ """Create a tenant and print what the person who runs this needs next."""
22
+ body = api.request("POST", "/v1/tenants",
23
+ json={"slug": slug, "display_name": display_name})
24
+
25
+ if not body.get("created", True):
26
+ print(f"tenant {body['tenant']!r} already exists")
27
+ else:
28
+ print(f"created tenant {body['tenant']!r}")
29
+ print(f" organisation {body['org_id']}")
30
+ # THE AUDIENCE. Generated by Zitadel, different in every environment, and
31
+ # unknowable without asking — so it is printed here rather than left to be
32
+ # looked up by whoever wires a client next.
33
+ print(f" audience {body['project_id']}")
34
+
35
+ if body.get("created", True):
36
+ # THE ORG HAS NO USERS, and that is deliberate rather than missing.
37
+ # Creating one means choosing a password or sending an invitation, and
38
+ # both are decisions about who gets into a customer's account. Saying
39
+ # so beats leaving somebody to discover an empty tenant.
40
+ print()
41
+ print(" No users yet — the organisation is empty. Add the customer's")
42
+ print(" first administrator in Zitadel, in the organisation above.")
43
+ return 0
44
+
45
+
46
+ def ls(api: Api) -> int:
47
+ tenants = api.request("GET", "/v1/tenants").get("tenants", [])
48
+ if not tenants:
49
+ print("no tenants")
50
+ return 0
51
+ width = max(len(t["slug"]) for t in tenants)
52
+ for tenant in tenants:
53
+ # FALLS BACK HERE, not in the record. A tenant with no display name is
54
+ # shown by its slug; storing the slug as the name would make a later
55
+ # re-run indistinguishable from somebody naming them that.
56
+ name = tenant.get("display_name") or ""
57
+ suffix = f" {name}" if name and name != tenant["slug"] else ""
58
+ print(f"{tenant['slug']:<{width}} {tenant['created_at'][:10]}{suffix}")
59
+ return 0
elan_cli/validate.py ADDED
@@ -0,0 +1,91 @@
1
+ """`elan validate` — say no here, where saying no is free.
2
+
3
+ THE SAME MODEL THE SERVICE USES, imported rather than reimplemented. The point
4
+ of validating locally is that a local yes means the service will also say yes;
5
+ a second copy of the schema would agree until it did not, and a CLI that is
6
+ confidently wrong is worse than one that says nothing.
7
+
8
+ WHAT THIS CATCHES THAT THE SCHEMA ALONE CANNOT: the cross-check. `elan.yaml`
9
+ and the Python MANIFESTs are in a validation relationship rather than a
10
+ duplicated one — the manifest says what the agent asks for, the deploy file says
11
+ what the platform will permit — and neither file can be checked against the
12
+ other without reading both. That needs the repo's own code to run, which is why
13
+ this command does something `POST /v1/validate` cannot do for a repo it has
14
+ never built.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ from elan_agentkit_contracts.deploy_spec import Problem, cross_check
22
+
23
+ from elan_cli.manifests import ManifestError, read
24
+ from elan_cli.project import Project, ProjectError, find
25
+
26
+
27
+ def run(start: Path | None = None, *, python: str | None = None) -> int:
28
+ try:
29
+ project = find(start)
30
+ except ProjectError as exc:
31
+ print(f"error: {exc}")
32
+ return 2
33
+
34
+ print(f"{project.spec_path}")
35
+ print(f" tenant {project.spec.tenant}")
36
+ print(f" project {project.spec.project}")
37
+ print(f" targets {len(project.spec.targets)} declared")
38
+
39
+ try:
40
+ manifests = read(project.targets_root, python=python)
41
+ except ManifestError as exc:
42
+ print(f"\nerror: {exc}")
43
+ return 2
44
+
45
+ print(f" found {len(manifests)} in {project.targets_root}")
46
+
47
+ problems = cross_check(project.spec, manifests)
48
+ if not problems:
49
+ print("\nok — this would be accepted.")
50
+ _warn_unenforced(project)
51
+ return 0
52
+
53
+ # EVERY PROBLEM, NOT THE FIRST. A developer fixing one, pushing, and waiting
54
+ # for a build to report the next is how a five-minute edit becomes an
55
+ # afternoon. `cross_check` returns them all for the same reason.
56
+ print(f"\nrefused — {len(problems)} problem(s):\n")
57
+ for problem in problems:
58
+ _print(problem)
59
+ return 1
60
+
61
+
62
+ def _print(problem: Problem) -> None:
63
+ print(f" {problem.where}")
64
+ for line in problem.message.splitlines():
65
+ print(f" {line}")
66
+ print()
67
+
68
+
69
+ def _warn_unenforced(project: Project) -> None:
70
+ """`paths:` is parsed, stored, shown — and NOT enforced.
71
+
72
+ Path-level egress needs TLS interception, which means the platform could
73
+ read a tenant's traffic to third parties. That is a consent decision rather
74
+ than a config flag, so v2 enforces the HOST and says so out loud. "Declared
75
+ but unenforced" silently read as "enforced" is how people get breached.
76
+ """
77
+ declared = sorted({
78
+ rule.host
79
+ for slug in project.spec.targets
80
+ for rule in (project.spec.resolved(slug).egress or [])
81
+ if rule.paths
82
+ })
83
+ if not declared:
84
+ return
85
+ print()
86
+ print(" note: `paths:` is recorded and shown, but NOT enforced. Only the")
87
+ print(" HOST is. Enforcing a path needs TLS interception, which")
88
+ print(" would let the platform read your traffic to that host — so")
89
+ print(" it is not done silently. Affected:")
90
+ for host in declared:
91
+ print(f" {host}")
elan_cli/wallet.py ADDED
@@ -0,0 +1,213 @@
1
+ """`elan wallet` — credit a tenant holds for THEIR OWN end users.
2
+
3
+ WHY THIS EXISTS, given the console has a page for it: this is the surface a
4
+ developer automates against. Topping up a user from a support script, seeding
5
+ promo credit in CI, or checking a balance while debugging a refusal are all things
6
+ somebody does from a terminal, and the alternative was `curl` with a hand-built
7
+ bearer. A CLI that mirrors the API is also the fastest way to find out the API is
8
+ wrong — which is exactly how the missing `eu:` join would have been caught.
9
+
10
+ DOLLARS IN, MICRO-USD ON THE WIRE. A person types `5` and means five dollars; the
11
+ ledger counts in micro-USD because a float price is a rounding argument with a
12
+ customer. The conversion is here, in one place, and `--micros` exists for the
13
+ caller who already has the integer and should not have to divide by a million to
14
+ pass it back.
15
+
16
+ NO `--tenant`. Every route derives the tenant from the verified token, so naming
17
+ one would be a value the service ignores — and offering a flag that does nothing
18
+ is worse than not offering it.
19
+
20
+ THE PROJECT IS NOT OPTIONAL BUT IS USUALLY IMPLICIT. A wallet is keyed on
21
+ `(tenant, project, external_user_id)` because a tenant runs several products and
22
+ `ada` in one is not `ada` in another. Run inside a project and `elan.yaml` answers
23
+ it, the way `elan deploy` already works; `--project` is for a script that is not
24
+ standing in one.
25
+
26
+ WHAT THIS DELIBERATELY CANNOT DO: close a wallet. Closing needs a zero balance and
27
+ is the one action here that is not meant to be reversed, so it stays in the console
28
+ where the balance is on screen beside the button. `freeze` covers the urgent case.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from elan_cli.client import Api
34
+ from elan_cli.project import ProjectError, find
35
+
36
+ #: Micro-USD in one dollar.
37
+ MICROS = 1_000_000
38
+
39
+
40
+ class WalletError(Exception):
41
+ """Phrased for the developer running the command."""
42
+
43
+
44
+ def _base(project: str) -> str:
45
+ """Every wallet route, under its project. One place, so no command can drop
46
+ half the key."""
47
+ return f"/v1/projects/{project}/wallets"
48
+
49
+
50
+ def project_of(explicit: str) -> str:
51
+ """The project to act in: what was asked for, or what `elan.yaml` says.
52
+
53
+ THE LOCAL FILE RATHER THAN A REMEMBERED DEFAULT. `elan deploy` resolves a
54
+ project the same way, so standing in a directory means the same thing to both
55
+ — and a remembered one would make `elan wallet credit ada 5` act on whichever
56
+ project was touched last, which is the shape of a mistake somebody only finds
57
+ in their statement.
58
+ """
59
+ if explicit.strip():
60
+ return explicit.strip()
61
+ try:
62
+ return find().spec.project
63
+ except ProjectError as exc:
64
+ raise WalletError(
65
+ "which project? run this inside one, or pass --project "
66
+ f"({exc})") from exc
67
+
68
+
69
+ def _micros(dollars: str | None, micros: int | None) -> int:
70
+ """The amount, in micro-USD, from whichever form the caller used.
71
+
72
+ ROUNDED RATHER THAN TRUNCATED. `1.1 * 1_000_000` is 1100000.0000001 in
73
+ binary floating point, and `int()` on that credits a user one micro-USD less
74
+ than they were promised — which nobody would ever find, and which makes a
75
+ statement that does not add up.
76
+ """
77
+ if micros is not None:
78
+ return micros
79
+ if dollars is None:
80
+ raise WalletError("give an amount in dollars, or --micros")
81
+ try:
82
+ return round(float(dollars) * MICROS)
83
+ except ValueError as exc:
84
+ raise WalletError(f"{dollars!r} is not an amount in dollars") from exc
85
+
86
+
87
+ def _money(micros: int) -> str:
88
+ """Micro-USD as dollars, without rounding a real charge away.
89
+
90
+ The same rule the console's `money()` follows: a charge of 1,477 micro-USD is
91
+ `$0.0015` and not `$0.00`, because a table of charges that reads as all zeroes
92
+ describes charges that were genuinely made.
93
+ """
94
+ major = micros / MICROS
95
+ if major == 0:
96
+ return "$0.00"
97
+ # THE SIGN GOES BEFORE THE SYMBOL. `$-2.50` is not how money is written, and a
98
+ # statement is mostly negatives — every run spends.
99
+ sign = "-" if major < 0 else ""
100
+ size = abs(major)
101
+ if size >= 0.01:
102
+ return f"{sign}${size:,.2f}"
103
+ return f"{sign}${size:.4f}"
104
+
105
+
106
+ def ls(api: Api, project: str, limit: int = 50) -> int:
107
+ page = api.request("GET", f"{_base(project)}?limit={limit}")
108
+ wallets = page.get("wallets", [])
109
+ if not wallets:
110
+ print("no wallets yet — `elan wallet create <user>` makes one")
111
+ return 0
112
+
113
+ width = max(len(w["external_user_id"]) for w in wallets)
114
+ for wallet in wallets:
115
+ held = wallet.get("held_micros") or 0
116
+ # HELD IS SHOWN ONLY WHEN THERE IS SOME, because it explains a balance
117
+ # that looks wrong — a run in flight has reserved its worst case and has
118
+ # not been charged yet — and is noise on every other row.
119
+ extra = f" ({_money(held)} held)" if held else ""
120
+ state = wallet.get("status", "")
121
+ label = f" {wallet['display_label']}" if wallet.get("display_label") else ""
122
+ print(f"{wallet['external_user_id']:<{width}} "
123
+ f"{_money(wallet.get('available_micros', 0)):>12} "
124
+ f"{state:<7}{extra}{label}")
125
+
126
+ total = page.get("total", len(wallets))
127
+ if total > len(wallets):
128
+ print(f"\n{len(wallets)} of {total}; --limit for more")
129
+ return 0
130
+
131
+
132
+ def show(api: Api, project: str, user: str) -> int:
133
+ wallet = api.request("GET", f"{_base(project)}/{user}")
134
+ print(f"user {wallet['external_user_id']}")
135
+ if wallet.get("display_label"):
136
+ print(f"label {wallet['display_label']}")
137
+ print(f"status {wallet.get('status', '')}")
138
+ print(f"available {_money(wallet.get('available_micros', 0))}")
139
+ if wallet.get("held_micros"):
140
+ print(f"held {_money(wallet['held_micros'])}"
141
+ " (reserved by a run in flight, not yet charged)")
142
+ print(f"balance {_money(wallet.get('balance_micros', 0))}")
143
+ return 0
144
+
145
+
146
+ def create(api: Api, project: str, user: str, label: str = "") -> int:
147
+ wallet = api.request("POST", _base(project),
148
+ json={"external_user_id": user, "display_label": label})
149
+ # IDEMPOTENT, SO THIS DOES NOT CLAIM TO HAVE CREATED ANYTHING. Running it on
150
+ # every sign-in is the expected pattern, and "created" on the fortieth call
151
+ # would be a lie somebody eventually relies on.
152
+ print(f"{wallet['external_user_id']} "
153
+ f"{_money(wallet.get('available_micros', 0))} "
154
+ f"{wallet.get('status', '')}")
155
+ return 0
156
+
157
+
158
+ def credit(api: Api, project: str, user: str, dollars: str | None,
159
+ micros: int | None, ref: str = "") -> int:
160
+ amount = _micros(dollars, micros)
161
+ if amount <= 0:
162
+ raise WalletError("a credit must be positive; use `adjust` to take credit away")
163
+ done = api.request("POST", f"{_base(project)}/{user}/credits",
164
+ json={"micros": amount, "ref": ref or None})
165
+ print(f"credited {_money(amount)} to {user} {done.get('transaction_id', '')}")
166
+ return 0
167
+
168
+
169
+ def adjust(api: Api, project: str, user: str, dollars: str | None,
170
+ micros: int | None, ref: str = "") -> int:
171
+ amount = _micros(dollars, micros)
172
+ if amount == 0:
173
+ raise WalletError("an adjustment of zero records nothing")
174
+ done = api.request("POST", f"{_base(project)}/{user}/adjustments",
175
+ json={"micros": amount, "ref": ref or None})
176
+ verb = "credited" if amount > 0 else "clawed back"
177
+ print(f"{verb} {_money(abs(amount))} on {user} "
178
+ f"{done.get('transaction_id', '')}")
179
+ return 0
180
+
181
+
182
+ def status(api: Api, project: str, user: str, state: str) -> int:
183
+ wallet = api.request("POST", f"{_base(project)}/{user}/status",
184
+ json={"status": state})
185
+ if state == "frozen":
186
+ # SAYS WHAT THE END USER WILL SEE, because that is the question somebody
187
+ # freezing an account is about to be asked.
188
+ print(f"{user} is frozen; their balance is untouched and runs are refused "
189
+ "as unavailable rather than out of credit")
190
+ else:
191
+ print(f"{user} is {wallet.get('status', state)}")
192
+ return 0
193
+
194
+
195
+ def statement(api: Api, project: str, user: str, limit: int = 25) -> int:
196
+ page = api.request("GET",
197
+ f"{_base(project)}/{user}/statement?limit={limit}")
198
+ entries = page.get("entries", [])
199
+ if not entries:
200
+ print("nothing has moved on this wallet yet")
201
+ return 0
202
+
203
+ words = {"grant": "credit", "settle": "run", "adjustment": "correction"}
204
+ for entry in entries:
205
+ kind = words.get(entry.get("kind", ""), entry.get("kind", ""))
206
+ ref = f" {entry['ref']}" if entry.get("ref") else ""
207
+ print(f"{entry.get('created_at', '')[:19]} {kind:<11} "
208
+ f"{_money(entry.get('amount_micros', 0)):>12}{ref}")
209
+
210
+ total = page.get("total", len(entries))
211
+ if total > len(entries):
212
+ print(f"\n{len(entries)} of {total}; --limit for more")
213
+ return 0
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.5
2
+ Name: elan-cli
3
+ Version: 0.1.0
4
+ Summary: The command line a developer deploys an agent with
5
+ Project-URL: Homepage, https://openelan.ai
6
+ Project-URL: Documentation, https://openelan.ai/app/docs
7
+ Author-email: Elan <support@openelan.ai>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: adk,agents,cli,deployment,llm
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: certifi>=2024.2.2
19
+ Requires-Dist: elan-agentkit-contracts
20
+ Requires-Dist: elan-sdk
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: keyring>=25.0
23
+ Requires-Dist: websockets>=13.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # elan-cli
27
+
28
+ The command line a developer deploys an agent with. Installs the `elan`
29
+ command.
30
+
31
+ ```bash
32
+ uv tool install elan-cli # or: pipx install elan-cli
33
+ ```
34
+
35
+ Requires Python 3.12 or newer.
36
+
37
+ ## Getting started
38
+
39
+ ```bash
40
+ elan login # device-code sign-in against the platform
41
+ elan init --tenant acme --project agents
42
+ elan env # build the project's venv
43
+ elan validate # say no here, where saying no is free
44
+ elan deploy
45
+ elan invoke invoice-triage --text "What do I owe on INV-9?"
46
+ ```
47
+
48
+ `elan login` reaches the production API by default. Point it elsewhere with
49
+ `elan login --api https://…`, or set `ELAN_API`.
50
+
51
+ ## What it covers
52
+
53
+ | Group | Commands |
54
+ |---|---|
55
+ | Signing in | `login`, `logout`, `whoami` |
56
+ | Project setup | `init`, `new {agent,workflow}`, `projects {ls,create}` |
57
+ | Local development | `env`, `adk`, `validate` |
58
+ | Shipping | `deploy`, `status`, `logs`, `rollback` |
59
+ | Operating | `agents`, `invoke`, `doctor`, `destroy` |
60
+ | Secrets | `secret {set,ls,rm}` |
61
+ | Files | `files {ls,get,put,rm}` |
62
+ | Wallets | `wallet {ls,show,create,credit,adjust,freeze,unfreeze,statement}` |
63
+
64
+ `elan <command> --help` for the flags.
65
+
66
+ ## Where it keeps things
67
+
68
+ Configuration is a plain JSON file at `~/.config/elan/config.json`, mode
69
+ `0600`, holding only public values — the API URL, the issuer, the client id.
70
+ Override the directory with `ELAN_HOME`, or the API with `ELAN_API`.
71
+
72
+ **Credentials never go in a file.** The refresh token is stored in the OS
73
+ keyring under the service name `elan-cli`. The CLI will not fall back to
74
+ writing one to disk; if `keyring` cannot be reached it says so and stops.
75
+
76
+ Per-project state lives in `.elan/` beside your `elan.yaml`, and is
77
+ git-ignored.
78
+
79
+ ## Exit codes
80
+
81
+ | | |
82
+ |---|---|
83
+ | `0` | it worked |
84
+ | `1` | it ran, and the answer was no — a refused deploy, a failed validation |
85
+ | `2` | it could not run — no project, no credential, no network |
86
+
87
+ Errors print as `error: …` on stderr with no traceback.
88
+
89
+ ## Documentation
90
+
91
+ The full command reference and the platform guides are at
92
+ <https://openelan.ai/app/docs>.
93
+
94
+ ## Licence
95
+
96
+ Apache-2.0. See `LICENSE`.
@@ -0,0 +1,28 @@
1
+ elan_cli/__init__.py,sha256=oTkVmV-vxUHF0Ew_TZXeUj4WhIZSc5FmrZUaBzIBgXY,220
2
+ elan_cli/adk.py,sha256=Wim59nEppwGWRTzXIvDWATUivByiphiIAuhhktXur5o,8144
3
+ elan_cli/auth.py,sha256=a9v2mQZGKzHPiWRz-HL8jSVN1oPcJUAfDTU8lxQvvSk,15614
4
+ elan_cli/client.py,sha256=Br-YdThu816lSwoIpYklb3hujv47lNARSXveym_1FmI,4862
5
+ elan_cli/config.py,sha256=RqSOFh_TK_5Bi6AqOQZPada90FAtS7gclJvOn_tBlkI,6109
6
+ elan_cli/deploy.py,sha256=UTe7SIQkMMP5eudRDwup6t3GnaRKtzKAJfxEuDzSCMk,9751
7
+ elan_cli/destroy.py,sha256=zQlb0pkFiD79bwq3TX5JSgkNS9sc14nRLBDaJrgcKYE,3180
8
+ elan_cli/doctor.py,sha256=zXB-CC7tCNQlX8ktkgD5ghGLwjcgQ-QhbgF1L_O6fGk,2535
9
+ elan_cli/env.py,sha256=53mNrnm7RrG3Q7tgWt0ARTMoa58xZ8i1n0i621nbOJE,4008
10
+ elan_cli/files.py,sha256=AIv3i-6yNhQ_1sgoMdgq1Lv0ecPQirUKM3OXCXLEkNk,6302
11
+ elan_cli/invoke.py,sha256=SsaHWrWRJoirfkKsvkqoZX-aiJjTYIFsq1jyiS4l_6Q,9394
12
+ elan_cli/logs.py,sha256=qpiqnipK8MgeTishyOWzJx7hs53ZmwstivCFEyx-9ws,6171
13
+ elan_cli/main.py,sha256=67ZBBWBggvK1vwf5GcZ4qTxt_hyExXtCnN_QYLmMawY,29499
14
+ elan_cli/manifests.py,sha256=lziJcrVn0qib70rpbSf-Lm0HSF_kUI0HgeMQluszOlw,5786
15
+ elan_cli/project.py,sha256=v7Vp4dw7JWesf70K2fJ0bMJwEJMqsLqtPWP1z0N-TFk,5662
16
+ elan_cli/projects.py,sha256=EeWdJ5BBE0sncOSgbZMxEZug8l5hpUu46EmAOpONOWw,2656
17
+ elan_cli/rollback.py,sha256=-wjAU6ZM4vDg_Rd3uHbrswShHW6ZZkVXCPxlZm4t7ho,2740
18
+ elan_cli/scaffold.py,sha256=6dEwaCTMg7riyydpFzEADsGIa6hjKhaOSBTpac9Ql2I,17094
19
+ elan_cli/secret.py,sha256=Y3sp866TPRsBzE2Iu4Ncw1mAQk3TtEv1FHEdmHPN7t8,4311
20
+ elan_cli/stream.py,sha256=ESvMHNivLUS3Q_Mzrm1TjCk9fXFcfXNm30PizamhKP4,7589
21
+ elan_cli/tenant.py,sha256=HjYvxyXlAwyGBClzPyH7nAQrGYHWCY1WLV3HXNV0Ta8,2697
22
+ elan_cli/validate.py,sha256=R3HIm5CFasHBprcc85XCCaFwN4oXuG4lWOoHu1ry8Rg,3383
23
+ elan_cli/wallet.py,sha256=YVXoqtO1IaLaubPccf_0TrV4rmNnIDZmx1c-Py-DrVw,9032
24
+ elan_cli-0.1.0.dist-info/METADATA,sha256=6fvz2T4RtX-E3X6cPjbHfzJWrNH03osoSBALVhL9xxQ,3066
25
+ elan_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
26
+ elan_cli-0.1.0.dist-info/entry_points.txt,sha256=nhqf7kifhbxc2pEurRsqWIr_GgA2FwfQQViKfw5fUUs,43
27
+ elan_cli-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
28
+ elan_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ elan = elan_cli.main:run