xcorecli 2.1.0__tar.gz → 2.2.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.
- {xcorecli-2.1.0 → xcorecli-2.2.0}/PKG-INFO +1 -1
- {xcorecli-2.1.0 → xcorecli-2.2.0}/pyproject.toml +1 -1
- {xcorecli-2.1.0 → xcorecli-2.2.0}/uv.lock +1 -1
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/plugin/install_commands.py +38 -2
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/plugin/marketplace_commands.py +41 -0
- xcorecli-2.2.0/xcli/plugin/shared.py +107 -0
- xcorecli-2.1.0/xcli/plugin/shared.py +0 -59
- {xcorecli-2.1.0 → xcorecli-2.2.0}/.github/workflows/publish.yml +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/.gitignore +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/.python-version +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/.vscode/configurationCache.log +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/.vscode/dryrun.log +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/.vscode/settings.json +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/.vscode/targets.log +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/README.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/commands/health.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/commands/init.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/commands/login.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/config/index.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/getting-started/auth.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/getting-started/configuration.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/getting-started/install.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/index.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/manager/index.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/manager/monitoring.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/manager/services.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/migration/index.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/plugin/index.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/plugin/install.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/plugin/local.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/plugin/marketplace.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/plugin/runtime.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/plugin/security.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/plugin/update.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/reference.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/sandbox/index.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/service/index.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/worker/index.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/docs/worker/process.md +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/makefile +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/mkdocs.yml +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/__init__.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/_credentials.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/_login.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/_run.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/_xcore.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/config/__init__.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/config/cli.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/config/runtime.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/init/__init__.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/init/manager.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/init/upgrade.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/main.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/manager/__init__.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/manager/cli.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/migrations/__init__.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/migrations/cli.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/migrations/runtime.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/plugin/__init__.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/plugin/cli.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/plugin/local_commands.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/plugin/runtime_commands.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/plugin/scaffold.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/plugin/security_commands.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/plugin/update_commands.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/sandbox/__init__.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/sandbox/cli.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/service/__init__.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/service/cli.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/service/install_commands.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/service/marketplace_commands.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/service/shared.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/worker/__init__.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/worker/cli.py +0 -0
- {xcorecli-2.1.0 → xcorecli-2.2.0}/xcli/worker/worker.py +0 -0
|
@@ -10,7 +10,7 @@ from typer import Typer
|
|
|
10
10
|
|
|
11
11
|
from xcli._credentials import get_api_key, get_signing_key
|
|
12
12
|
|
|
13
|
-
from .shared import console, marketplace_install_url, plugins_dir
|
|
13
|
+
from .shared import console, marketplace_install_url, parse_x_repo, plugins_dir, record_install
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def _verify_signature(zip_bytes: bytes, x_signature: str, signing_key: str) -> bool:
|
|
@@ -127,6 +127,20 @@ def _marketplace_install(name: str, version: str) -> None:
|
|
|
127
127
|
|
|
128
128
|
console.print(f'[green]✓[/green] [cyan]{x_plugin}[/cyan] installed in [dim]{dest}[/dim]')
|
|
129
129
|
|
|
130
|
+
resolved_version = x_plugin.rsplit('@', 1)[-1] if '@' in x_plugin else version
|
|
131
|
+
repository, ref = parse_x_repo(resp.headers.get('X-Repo'))
|
|
132
|
+
record_install(
|
|
133
|
+
name,
|
|
134
|
+
{
|
|
135
|
+
'source': 'marketplace',
|
|
136
|
+
'slug': name,
|
|
137
|
+
'kind': 'plugin',
|
|
138
|
+
'version': resolved_version,
|
|
139
|
+
'repository': repository,
|
|
140
|
+
'ref': ref,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
130
144
|
|
|
131
145
|
def _install_from_url(name: str, url: str, source_type: str) -> None:
|
|
132
146
|
import io
|
|
@@ -139,7 +153,9 @@ def _install_from_url(name: str, url: str, source_type: str) -> None:
|
|
|
139
153
|
shutil.rmtree(dest)
|
|
140
154
|
dest.mkdir(parents=True, exist_ok=True)
|
|
141
155
|
|
|
142
|
-
|
|
156
|
+
resolved_ref: Optional[str] = None
|
|
157
|
+
used_git = source_type == 'git' or url.endswith('.git')
|
|
158
|
+
if used_git:
|
|
143
159
|
import subprocess
|
|
144
160
|
|
|
145
161
|
console.print(f' Cloning [dim]{url}[/dim]...')
|
|
@@ -147,6 +163,14 @@ def _install_from_url(name: str, url: str, source_type: str) -> None:
|
|
|
147
163
|
if result.returncode != 0:
|
|
148
164
|
console.print(f'[red]git clone failed:[/red] {result.stderr.decode().strip()}')
|
|
149
165
|
raise typer.Exit(1)
|
|
166
|
+
# --depth=1 leaves HEAD at whatever the clone resolved (default
|
|
167
|
+
# branch tip, since no ref was requested) — pin the registry entry
|
|
168
|
+
# to that exact commit rather than a mutable branch name, so a
|
|
169
|
+
# later `xcore-agent build` re-resolving from it gets the same code.
|
|
170
|
+
head = subprocess.run(
|
|
171
|
+
['git', '-C', str(dest), 'rev-parse', 'HEAD'], capture_output=True, text=True
|
|
172
|
+
)
|
|
173
|
+
resolved_ref = head.stdout.strip() if head.returncode == 0 else None
|
|
150
174
|
else:
|
|
151
175
|
import urllib.request
|
|
152
176
|
|
|
@@ -176,6 +200,18 @@ def _install_from_url(name: str, url: str, source_type: str) -> None:
|
|
|
176
200
|
|
|
177
201
|
console.print(f'[green]✓[/green] [cyan]{name}[/cyan] installed in [dim]{dest}[/dim]')
|
|
178
202
|
|
|
203
|
+
record_install(
|
|
204
|
+
name,
|
|
205
|
+
{
|
|
206
|
+
'source': 'git' if used_git else 'zip',
|
|
207
|
+
'slug': name,
|
|
208
|
+
'kind': 'plugin',
|
|
209
|
+
'version': None,
|
|
210
|
+
'repository': url if used_git else None,
|
|
211
|
+
'ref': resolved_ref,
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
179
215
|
|
|
180
216
|
# Module-level (not a closure inside register()) so the exact same function
|
|
181
217
|
# object can be registered a second time as the top-level `xcli install`
|
|
@@ -6,6 +6,8 @@ import httpx
|
|
|
6
6
|
import typer
|
|
7
7
|
from typer import Typer
|
|
8
8
|
|
|
9
|
+
from xcli._credentials import get_api_key
|
|
10
|
+
|
|
9
11
|
from .shared import console, marketplace_api_base
|
|
10
12
|
|
|
11
13
|
# Contrat réel du backend (app/marketplace/src/routes/plugins.py) — pas le
|
|
@@ -90,6 +92,45 @@ def register(app: Typer) -> None:
|
|
|
90
92
|
return
|
|
91
93
|
_table(items, f'Search results — {query}')
|
|
92
94
|
|
|
95
|
+
@app.command('mine')
|
|
96
|
+
def mine() -> None:
|
|
97
|
+
"""List YOUR plugins on the marketplace — public and private alike.
|
|
98
|
+
|
|
99
|
+
`browse`/`search` above only ever see public plugins: they call
|
|
100
|
+
GET /plugins unauthenticated, and the server only widens that to a
|
|
101
|
+
caller's own private plugins for a JWT session — which `xcli` never
|
|
102
|
+
holds (even after `xcli login`, which saves an API key + signing
|
|
103
|
+
key via a device-code flow, never a browser session token). This
|
|
104
|
+
command instead calls GET /plugins/mine with X-API-Key — the same
|
|
105
|
+
auth `xcli plugin install` already uses successfully to fetch a
|
|
106
|
+
private plugin you own, now also available for listing them.
|
|
107
|
+
"""
|
|
108
|
+
api_key = get_api_key()
|
|
109
|
+
if not api_key:
|
|
110
|
+
console.print('[red]API key missing.[/red] Run: [cyan]xcli login[/cyan] or [cyan]xcli config set api-key xdk_...[/cyan]')
|
|
111
|
+
raise typer.Exit(1)
|
|
112
|
+
|
|
113
|
+
url = f'{marketplace_api_base()}{_LIST_PATH}/mine'
|
|
114
|
+
with console.status('Fetching your plugins...'):
|
|
115
|
+
try:
|
|
116
|
+
resp = httpx.get(url, headers={'X-API-Key': api_key}, timeout=15, follow_redirects=True)
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
except httpx.HTTPStatusError as exc:
|
|
119
|
+
if exc.response.status_code == 401:
|
|
120
|
+
console.print('[red]Invalid or revoked API key.[/red] Run: [cyan]xcli login[/cyan]')
|
|
121
|
+
else:
|
|
122
|
+
console.print(f'[red]HTTP {exc.response.status_code}:[/red] {exc.response.text[:200]}')
|
|
123
|
+
raise typer.Exit(1)
|
|
124
|
+
except httpx.RequestError as exc:
|
|
125
|
+
console.print(f'[red]Network error:[/red] {exc}')
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
|
|
128
|
+
items = resp.json() or []
|
|
129
|
+
if not items:
|
|
130
|
+
console.print('[yellow]No plugins yet.[/yellow] Publish one: [dim]xcli plugin marketplace info <slug>[/dim]')
|
|
131
|
+
return
|
|
132
|
+
_table(items, f'Your Plugins ({len(items)})')
|
|
133
|
+
|
|
93
134
|
@app.command('info')
|
|
94
135
|
def info(slug: str) -> None:
|
|
95
136
|
"""Show full details for a marketplace plugin before installing."""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.tree import Tree
|
|
11
|
+
|
|
12
|
+
from xcli.config.runtime import load_raw_config, plugins_directory
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
name_re = re.compile(r'^[a-z][a-z0-9_]*$')
|
|
16
|
+
|
|
17
|
+
_DEFAULT_MARKETPLACE_API = 'https://marketplace.xcorehub.dev'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def marketplace_api_base() -> str:
|
|
21
|
+
"""Return the marketplace API base URL from integration.yaml, with fallback.
|
|
22
|
+
|
|
23
|
+
`integration.yaml`'s `marketplace.url` is conventionally a bare domain
|
|
24
|
+
(`marketplace.xcorehub.dev`, no scheme — see the backend's own
|
|
25
|
+
integration.yaml) since it's also used to build the CORS allow-list.
|
|
26
|
+
Used directly as an HTTP base URL that would produce a scheme-less,
|
|
27
|
+
unusable URL for httpx — always ensure a scheme here rather than assume
|
|
28
|
+
every caller of this config value remembers to add one.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
cfg = load_raw_config(required=False)
|
|
32
|
+
marketplace = cfg.get('marketplace', {})
|
|
33
|
+
url = str(marketplace.get('api_url') or marketplace.get('url') or _DEFAULT_MARKETPLACE_API).rstrip('/')
|
|
34
|
+
except Exception:
|
|
35
|
+
url = _DEFAULT_MARKETPLACE_API
|
|
36
|
+
if not url.startswith(('http://', 'https://')):
|
|
37
|
+
url = f'https://{url}'
|
|
38
|
+
return url
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def marketplace_install_url(name: str, version: str) -> str:
|
|
42
|
+
return f'{marketplace_api_base()}/app/marketplace/plugins/{name}/install?version={version}'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def plugins_dir() -> Path:
|
|
46
|
+
return plugins_directory()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def registry_path() -> Path:
|
|
50
|
+
"""`.xcore-registry.json` — a sibling of every installed plugin
|
|
51
|
+
directory, one entry per plugin name, recording how it got there
|
|
52
|
+
(marketplace/git/zip + repository + pinned ref when known). Read by
|
|
53
|
+
xcore-agent's packer at build time (see packer.builder._read_registry_
|
|
54
|
+
source) as a fallback for a plugin with no explicit `source:` of its
|
|
55
|
+
own in plugin.yaml — this is what lets `xcore-agent build` resolve a
|
|
56
|
+
plugin from its real origin at deploy time automatically, instead of
|
|
57
|
+
an operator hand-writing `source:` after every `xcli plugin install`."""
|
|
58
|
+
return plugins_dir() / ".xcore-registry.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def record_install(name: str, entry: dict[str, Any]) -> None:
|
|
62
|
+
"""Add/replace this plugin's entry in the registry. Best-effort: a
|
|
63
|
+
write failure here must never fail the install itself (the plugin is
|
|
64
|
+
already on disk and usable — the registry only makes a later `xcore-
|
|
65
|
+
agent build` smarter, it's not required for the plugin to work)."""
|
|
66
|
+
path = registry_path()
|
|
67
|
+
try:
|
|
68
|
+
registry = json.loads(path.read_text()) if path.is_file() else {}
|
|
69
|
+
if not isinstance(registry, dict):
|
|
70
|
+
registry = {}
|
|
71
|
+
except (OSError, ValueError):
|
|
72
|
+
registry = {}
|
|
73
|
+
registry[name] = {**entry, "installed_at": datetime.now(timezone.utc).isoformat()}
|
|
74
|
+
try:
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
path.write_text(json.dumps(registry, indent=2, sort_keys=True) + "\n")
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
console.print(f"[dim yellow]Note: couldn't update {path.name}: {exc}[/dim yellow]")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_x_repo(header_value: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
|
82
|
+
"""Parse the marketplace install endpoint's `X-Repo: owner/repo@ref`
|
|
83
|
+
response header into (repository_url, ref). Returns (None, None) if the
|
|
84
|
+
header is absent or malformed — callers must treat that as 'unknown
|
|
85
|
+
origin', never guess."""
|
|
86
|
+
if not header_value or "@" not in header_value:
|
|
87
|
+
return None, None
|
|
88
|
+
repo_part, _, ref_part = header_value.rpartition("@")
|
|
89
|
+
if not repo_part or not ref_part:
|
|
90
|
+
return None, None
|
|
91
|
+
return f"https://github.com/{repo_part}", ref_part
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def print_tree(root: Path, created: list[Path]) -> None:
|
|
95
|
+
tree = Tree(f'[bold green]{root.name}/[/bold green]')
|
|
96
|
+
dirs: dict[str, Tree] = {}
|
|
97
|
+
for path in sorted(created):
|
|
98
|
+
rel = path.relative_to(root)
|
|
99
|
+
parts = rel.parts
|
|
100
|
+
current = tree
|
|
101
|
+
for i, part in enumerate(parts[:-1]):
|
|
102
|
+
key = '/'.join(parts[: i + 1])
|
|
103
|
+
if key not in dirs:
|
|
104
|
+
dirs[key] = current.add(f'[cyan]{part}/[/cyan]')
|
|
105
|
+
current = dirs[key]
|
|
106
|
+
current.add(f'[white]{parts[-1]}[/white]')
|
|
107
|
+
console.print(tree)
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
from rich.console import Console
|
|
7
|
-
from rich.tree import Tree
|
|
8
|
-
|
|
9
|
-
from xcli.config.runtime import load_raw_config, plugins_directory
|
|
10
|
-
|
|
11
|
-
console = Console()
|
|
12
|
-
name_re = re.compile(r'^[a-z][a-z0-9_]*$')
|
|
13
|
-
|
|
14
|
-
_DEFAULT_MARKETPLACE_API = 'https://marketplace.xcorehub.dev'
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def marketplace_api_base() -> str:
|
|
18
|
-
"""Return the marketplace API base URL from integration.yaml, with fallback.
|
|
19
|
-
|
|
20
|
-
`integration.yaml`'s `marketplace.url` is conventionally a bare domain
|
|
21
|
-
(`marketplace.xcorehub.dev`, no scheme — see the backend's own
|
|
22
|
-
integration.yaml) since it's also used to build the CORS allow-list.
|
|
23
|
-
Used directly as an HTTP base URL that would produce a scheme-less,
|
|
24
|
-
unusable URL for httpx — always ensure a scheme here rather than assume
|
|
25
|
-
every caller of this config value remembers to add one.
|
|
26
|
-
"""
|
|
27
|
-
try:
|
|
28
|
-
cfg = load_raw_config(required=False)
|
|
29
|
-
marketplace = cfg.get('marketplace', {})
|
|
30
|
-
url = str(marketplace.get('api_url') or marketplace.get('url') or _DEFAULT_MARKETPLACE_API).rstrip('/')
|
|
31
|
-
except Exception:
|
|
32
|
-
url = _DEFAULT_MARKETPLACE_API
|
|
33
|
-
if not url.startswith(('http://', 'https://')):
|
|
34
|
-
url = f'https://{url}'
|
|
35
|
-
return url
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def marketplace_install_url(name: str, version: str) -> str:
|
|
39
|
-
return f'{marketplace_api_base()}/app/marketplace/plugins/{name}/install?version={version}'
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def plugins_dir() -> Path:
|
|
43
|
-
return plugins_directory()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def print_tree(root: Path, created: list[Path]) -> None:
|
|
47
|
-
tree = Tree(f'[bold green]{root.name}/[/bold green]')
|
|
48
|
-
dirs: dict[str, Tree] = {}
|
|
49
|
-
for path in sorted(created):
|
|
50
|
-
rel = path.relative_to(root)
|
|
51
|
-
parts = rel.parts
|
|
52
|
-
current = tree
|
|
53
|
-
for i, part in enumerate(parts[:-1]):
|
|
54
|
-
key = '/'.join(parts[: i + 1])
|
|
55
|
-
if key not in dirs:
|
|
56
|
-
dirs[key] = current.add(f'[cyan]{part}/[/cyan]')
|
|
57
|
-
current = dirs[key]
|
|
58
|
-
current.add(f'[white]{parts[-1]}[/white]')
|
|
59
|
-
console.print(tree)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|