devsync 0.12.0__tar.gz → 0.13.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.
- {devsync-0.12.0 → devsync-0.13.0}/PKG-INFO +2 -1
- {devsync-0.12.0 → devsync-0.13.0}/README.md +1 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/install_v2.py +96 -10
- {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/main.py +9 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/component_detector.py +29 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/extractor.py +2 -1
- devsync-0.13.0/devsync/core/pip_utils.py +314 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/practice.py +9 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/prompts.py +5 -1
- {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/PKG-INFO +2 -1
- {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/SOURCES.txt +1 -0
- {devsync-0.12.0 → devsync-0.13.0}/pyproject.toml +1 -1
- {devsync-0.12.0 → devsync-0.13.0}/LICENSE +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/__init__.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/__main__.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/__init__.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/aider.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/amazonq.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/amp.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/anteroom.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/antigravity.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/augment.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/base.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/capability_registry.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/claude.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/claude_desktop.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/cline.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/codex.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/continuedev.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/copilot.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/cursor.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/detector.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/gemini.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/jetbrains.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/junie.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/kiro.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/mcp_syncer.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/opencode.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/openhands.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/roo.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/tabnine.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/trae.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/translator.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/winsurf.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/zed.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/__init__.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/extract.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/list_v2.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/setup.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/tools.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/uninstall.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/__init__.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/adapter.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/checksum.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/conflict_resolution.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/git_operations.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp/__init__.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp/credentials.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp/manager.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp/set_manager.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp/validator.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp_credential_prompter.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/models.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/package_creator.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/package_manifest.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/package_manifest_v2.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/repository.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/secret_detector.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/core/version.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/__init__.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/anthropic.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/config.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/openai_provider.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/openrouter.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/provider.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/response_models.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/storage/__init__.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/storage/mcp_tracker.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/storage/package_tracker.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/storage/tracker.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/tui/__init__.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/__init__.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/atomic_write.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/backup.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/dotenv.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/git_helpers.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/logging.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/namespace.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/paths.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/project.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/streaming.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/ui.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/validation.py +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/dependency_links.txt +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/entry_points.txt +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/requires.txt +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/top_level.txt +0 -0
- {devsync-0.12.0 → devsync-0.13.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: devsync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Distribute and sync dev tool configurations across teams
|
|
5
5
|
Author-email: Troy Larson <troy@calvinware.com>
|
|
6
6
|
License: MIT License
|
|
@@ -86,6 +86,7 @@ No API key? DevSync works without one -- it falls back to file-copy mode. Add `-
|
|
|
86
86
|
- **AI-powered extraction** -- LLM reads your project's rules, MCP configs, and commands to produce abstract practice declarations
|
|
87
87
|
- **AI-powered installation** -- LLM adapts incoming practices to your existing setup with intelligent merging
|
|
88
88
|
- **23+ AI tool integrations** -- Claude Code, Cursor, Windsurf, GitHub Copilot, Kiro, Roo Code, Cline, Codex, and more
|
|
89
|
+
- **MCP server dependencies** -- auto-detects pip-installable MCP servers and prompts to install them (`--skip-pip` to skip)
|
|
89
90
|
- **MCP credential handling** -- prompts for credentials at install time, never stores them in repos
|
|
90
91
|
- **v1 backward compatibility** -- old `ai-config-kit-package.yaml` packages still install via file-copy
|
|
91
92
|
- **Graceful degradation** -- works without an API key, `--no-ai` flag for explicit file-copy mode
|
|
@@ -47,6 +47,7 @@ No API key? DevSync works without one -- it falls back to file-copy mode. Add `-
|
|
|
47
47
|
- **AI-powered extraction** -- LLM reads your project's rules, MCP configs, and commands to produce abstract practice declarations
|
|
48
48
|
- **AI-powered installation** -- LLM adapts incoming practices to your existing setup with intelligent merging
|
|
49
49
|
- **23+ AI tool integrations** -- Claude Code, Cursor, Windsurf, GitHub Copilot, Kiro, Roo Code, Cline, Codex, and more
|
|
50
|
+
- **MCP server dependencies** -- auto-detects pip-installable MCP servers and prompts to install them (`--skip-pip` to skip)
|
|
50
51
|
- **MCP credential handling** -- prompts for credentials at install time, never stores them in repos
|
|
51
52
|
- **v1 backward compatibility** -- old `ai-config-kit-package.yaml` packages still install via file-copy
|
|
52
53
|
- **Graceful degradation** -- works without an API key, `--no-ai` flag for explicit file-copy mode
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"""V2 install command — AI-powered package installation."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import shutil
|
|
4
6
|
import tempfile
|
|
5
7
|
from pathlib import Path
|
|
6
|
-
from typing import Optional
|
|
8
|
+
from typing import TYPE_CHECKING, Optional
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from devsync.core.practice import MCPDeclaration
|
|
7
12
|
|
|
8
13
|
from rich.console import Console
|
|
9
14
|
from rich.prompt import Confirm
|
|
@@ -26,6 +31,7 @@ def install_v2_command(
|
|
|
26
31
|
no_ai: bool = False,
|
|
27
32
|
conflict: str = "prompt",
|
|
28
33
|
project_dir: Optional[str] = None,
|
|
34
|
+
skip_pip: bool = False,
|
|
29
35
|
) -> int:
|
|
30
36
|
"""Install a package into the current project.
|
|
31
37
|
|
|
@@ -37,6 +43,7 @@ def install_v2_command(
|
|
|
37
43
|
no_ai: Disable AI-powered adaptation.
|
|
38
44
|
conflict: Conflict strategy ('prompt', 'skip', 'overwrite', 'rename').
|
|
39
45
|
project_dir: Target project directory. Defaults to cwd.
|
|
46
|
+
skip_pip: Skip pip package installations for MCP servers.
|
|
40
47
|
|
|
41
48
|
Returns:
|
|
42
49
|
Exit code (0 = success).
|
|
@@ -72,8 +79,8 @@ def install_v2_command(
|
|
|
72
79
|
console.print(f" Tools: {', '.join(target_tools)}")
|
|
73
80
|
|
|
74
81
|
if manifest.is_v2 and manifest.has_practices and not no_ai:
|
|
75
|
-
return _install_v2_ai(manifest, project_root, target_tools)
|
|
76
|
-
return _install_v2_fallback(manifest, package_path, project_root, target_tools, conflict)
|
|
82
|
+
return _install_v2_ai(manifest, project_root, target_tools, skip_pip=skip_pip)
|
|
83
|
+
return _install_v2_fallback(manifest, package_path, project_root, target_tools, conflict, skip_pip=skip_pip)
|
|
77
84
|
finally:
|
|
78
85
|
if cloned_tmp and cloned_tmp.exists():
|
|
79
86
|
shutil.rmtree(cloned_tmp, ignore_errors=True)
|
|
@@ -125,6 +132,7 @@ def _install_v2_ai(
|
|
|
125
132
|
manifest: PackageManifestV2,
|
|
126
133
|
project_root: Path,
|
|
127
134
|
target_tools: list[str],
|
|
135
|
+
skip_pip: bool = False,
|
|
128
136
|
) -> int:
|
|
129
137
|
"""Install using AI-powered adaptation."""
|
|
130
138
|
config = load_config()
|
|
@@ -142,7 +150,7 @@ def _install_v2_ai(
|
|
|
142
150
|
_execute_plan(plan, project_root, target_tools)
|
|
143
151
|
|
|
144
152
|
if manifest.mcp_servers:
|
|
145
|
-
_install_mcp_servers(manifest, project_root)
|
|
153
|
+
_install_mcp_servers(manifest, project_root, skip_pip=skip_pip)
|
|
146
154
|
|
|
147
155
|
console.print(f"\n[green]Installed {manifest.name} successfully.[/green]")
|
|
148
156
|
return 0
|
|
@@ -154,6 +162,7 @@ def _install_v2_fallback(
|
|
|
154
162
|
project_root: Path,
|
|
155
163
|
target_tools: list[str],
|
|
156
164
|
conflict: str,
|
|
165
|
+
skip_pip: bool = False,
|
|
157
166
|
) -> int:
|
|
158
167
|
"""Install using file-copy mode (v1 compat or --no-ai)."""
|
|
159
168
|
installed_count = 0
|
|
@@ -205,7 +214,7 @@ def _install_v2_fallback(
|
|
|
205
214
|
console.print(f" Installed: {ref.name} → {dest.relative_to(project_root)}")
|
|
206
215
|
|
|
207
216
|
if manifest.mcp_servers:
|
|
208
|
-
_install_mcp_servers(manifest, project_root)
|
|
217
|
+
_install_mcp_servers(manifest, project_root, skip_pip=skip_pip)
|
|
209
218
|
|
|
210
219
|
console.print(f"\n[green]Installed {installed_count} instructions.[/green]")
|
|
211
220
|
return 0
|
|
@@ -258,17 +267,94 @@ def _get_tool_instruction_path(tool_name: str, project_root: Path, instruction_n
|
|
|
258
267
|
return project_root / dir_name / f"{instruction_name}{ext}"
|
|
259
268
|
|
|
260
269
|
|
|
261
|
-
def _install_mcp_servers(
|
|
262
|
-
|
|
263
|
-
|
|
270
|
+
def _install_mcp_servers(
|
|
271
|
+
manifest: PackageManifestV2,
|
|
272
|
+
project_root: Path,
|
|
273
|
+
skip_pip: bool = False,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Install MCP server configurations with pip dependencies and credential prompting."""
|
|
276
|
+
failed_pip_servers = _install_pip_dependencies(manifest.mcp_servers, skip_pip=skip_pip)
|
|
277
|
+
|
|
278
|
+
# Skip credential prompting for servers whose pip deps failed
|
|
279
|
+
eligible_servers = [s for s in manifest.mcp_servers if s.name not in failed_pip_servers]
|
|
280
|
+
servers_with_creds = [s for s in eligible_servers if s.credentials]
|
|
264
281
|
if servers_with_creds:
|
|
265
282
|
env_path = project_root / ".devsync" / ".env"
|
|
266
283
|
credentials = prompt_mcp_credentials(servers_with_creds, env_path=env_path)
|
|
267
284
|
|
|
268
|
-
for server in
|
|
285
|
+
for server in eligible_servers:
|
|
269
286
|
server_creds = credentials.get(server.name, {})
|
|
270
287
|
build_mcp_config(server, server_creds)
|
|
271
288
|
console.print(f" MCP: {server.name} configured")
|
|
272
289
|
else:
|
|
273
|
-
for server in
|
|
290
|
+
for server in eligible_servers:
|
|
274
291
|
console.print(f" MCP: {server.name} (no credentials needed)")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _install_pip_dependencies(
|
|
295
|
+
mcp_servers: list[MCPDeclaration],
|
|
296
|
+
skip_pip: bool = False,
|
|
297
|
+
) -> set[str]:
|
|
298
|
+
"""Install pip package dependencies for MCP servers.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
mcp_servers: List of MCPDeclaration objects.
|
|
302
|
+
skip_pip: If True, skip all pip installations.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Set of server names whose pip dependency installation failed or was declined.
|
|
306
|
+
"""
|
|
307
|
+
from devsync.core.pip_utils import (
|
|
308
|
+
get_installed_version,
|
|
309
|
+
install_pip_package,
|
|
310
|
+
installed_version_satisfies,
|
|
311
|
+
validate_pip_spec,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
failed_servers: set[str] = set()
|
|
315
|
+
servers_with_pip = [s for s in mcp_servers if s.pip_package]
|
|
316
|
+
if not servers_with_pip:
|
|
317
|
+
return failed_servers
|
|
318
|
+
|
|
319
|
+
console.print("\n[bold]MCP Server Dependencies[/bold]")
|
|
320
|
+
|
|
321
|
+
if skip_pip:
|
|
322
|
+
console.print(" [yellow]Skipping pip installations (--skip-pip)[/yellow]")
|
|
323
|
+
for server in servers_with_pip:
|
|
324
|
+
console.print(f" [dim]{server.name}: {server.pip_package} (skipped)[/dim]")
|
|
325
|
+
return failed_servers
|
|
326
|
+
|
|
327
|
+
for server in servers_with_pip:
|
|
328
|
+
spec = server.pip_package
|
|
329
|
+
assert spec is not None # guarded by servers_with_pip filter
|
|
330
|
+
|
|
331
|
+
if not validate_pip_spec(spec):
|
|
332
|
+
console.print(f" [red]Invalid package spec for {server.name}: {spec}[/red]")
|
|
333
|
+
failed_servers.add(server.name)
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
if installed_version_satisfies(spec):
|
|
337
|
+
installed_ver = get_installed_version(spec)
|
|
338
|
+
console.print(f" [dim]{server.name}: {spec} already installed (v{installed_ver})[/dim]")
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
console.print(f" [cyan]{server.name} requires pip package: {spec}[/cyan]")
|
|
342
|
+
if server.description:
|
|
343
|
+
console.print(f" [dim]{server.description}[/dim]")
|
|
344
|
+
|
|
345
|
+
if not Confirm.ask(f" Install {spec}?", default=True):
|
|
346
|
+
console.print(f" [yellow]Skipped pip install for {server.name}[/yellow]")
|
|
347
|
+
failed_servers.add(server.name)
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
with console.status(f" Installing {spec}..."):
|
|
351
|
+
success, message = install_pip_package(spec)
|
|
352
|
+
|
|
353
|
+
if success:
|
|
354
|
+
console.print(f" [green]{message}[/green]")
|
|
355
|
+
else:
|
|
356
|
+
console.print(f" [red]{message}[/red]")
|
|
357
|
+
console.print(f" [yellow]MCP server {server.name} may not work without {spec}[/yellow]")
|
|
358
|
+
failed_servers.add(server.name)
|
|
359
|
+
|
|
360
|
+
return failed_servers
|
|
@@ -130,6 +130,11 @@ def install(
|
|
|
130
130
|
"-p",
|
|
131
131
|
help="Target project directory (default: current directory)",
|
|
132
132
|
),
|
|
133
|
+
skip_pip: bool = typer.Option(
|
|
134
|
+
False,
|
|
135
|
+
"--skip-pip",
|
|
136
|
+
help="Skip pip package installations for MCP servers",
|
|
137
|
+
),
|
|
133
138
|
) -> None:
|
|
134
139
|
"""Install a package into the current project.
|
|
135
140
|
|
|
@@ -151,6 +156,9 @@ def install(
|
|
|
151
156
|
|
|
152
157
|
# Skip conflicts
|
|
153
158
|
devsync install ./package --conflict skip
|
|
159
|
+
|
|
160
|
+
# Skip pip installations
|
|
161
|
+
devsync install ./package --skip-pip
|
|
154
162
|
"""
|
|
155
163
|
from devsync.cli.install_v2 import install_v2_command
|
|
156
164
|
|
|
@@ -160,6 +168,7 @@ def install(
|
|
|
160
168
|
no_ai=no_ai,
|
|
161
169
|
conflict=conflict,
|
|
162
170
|
project_dir=project_dir,
|
|
171
|
+
skip_pip=skip_pip,
|
|
163
172
|
)
|
|
164
173
|
raise typer.Exit(code=exit_code)
|
|
165
174
|
|
|
@@ -58,6 +58,7 @@ class DetectedMCPServer:
|
|
|
58
58
|
config: dict
|
|
59
59
|
source: str
|
|
60
60
|
env_vars: list[str] = field(default_factory=list)
|
|
61
|
+
pip_package: Optional[str] = None
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
@dataclass
|
|
@@ -424,6 +425,10 @@ class ComponentDetector:
|
|
|
424
425
|
mcp_servers = config_data.get("mcpServers", {})
|
|
425
426
|
for server_name, server_config in mcp_servers.items():
|
|
426
427
|
env_vars = list(server_config.get("env", {}).keys())
|
|
428
|
+
pip_package = self._resolve_pip_package(
|
|
429
|
+
server_config.get("command", ""),
|
|
430
|
+
server_config.get("args", []),
|
|
431
|
+
)
|
|
427
432
|
servers.append(
|
|
428
433
|
DetectedMCPServer(
|
|
429
434
|
name=server_name,
|
|
@@ -431,6 +436,7 @@ class ComponentDetector:
|
|
|
431
436
|
config=server_config,
|
|
432
437
|
source=config_location,
|
|
433
438
|
env_vars=env_vars,
|
|
439
|
+
pip_package=pip_package,
|
|
434
440
|
)
|
|
435
441
|
)
|
|
436
442
|
except json.JSONDecodeError as e:
|
|
@@ -445,6 +451,10 @@ class ComponentDetector:
|
|
|
445
451
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
446
452
|
server_config = json.load(f)
|
|
447
453
|
env_vars = list(server_config.get("env", {}).keys())
|
|
454
|
+
pip_package = self._resolve_pip_package(
|
|
455
|
+
server_config.get("command", ""),
|
|
456
|
+
server_config.get("args", []),
|
|
457
|
+
)
|
|
448
458
|
servers.append(
|
|
449
459
|
DetectedMCPServer(
|
|
450
460
|
name=file_path.stem,
|
|
@@ -452,6 +462,7 @@ class ComponentDetector:
|
|
|
452
462
|
config=server_config,
|
|
453
463
|
source=str(file_path.relative_to(self.project_root)),
|
|
454
464
|
env_vars=env_vars,
|
|
465
|
+
pip_package=pip_package,
|
|
455
466
|
)
|
|
456
467
|
)
|
|
457
468
|
except Exception as e:
|
|
@@ -459,6 +470,24 @@ class ComponentDetector:
|
|
|
459
470
|
|
|
460
471
|
return servers
|
|
461
472
|
|
|
473
|
+
def _resolve_pip_package(self, command: str, args: list[str]) -> Optional[str]:
|
|
474
|
+
"""Attempt to resolve a pip package from an MCP server command.
|
|
475
|
+
|
|
476
|
+
Non-fatal: returns None on any failure.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
command: Server executable command.
|
|
480
|
+
args: Server command arguments.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
Pip package name or None.
|
|
484
|
+
"""
|
|
485
|
+
if not command:
|
|
486
|
+
return None
|
|
487
|
+
from devsync.core.pip_utils import resolve_pip_package_for_command
|
|
488
|
+
|
|
489
|
+
return resolve_pip_package_for_command(command, args)
|
|
490
|
+
|
|
462
491
|
def _detect_hooks(self) -> list[DetectedHook]:
|
|
463
492
|
"""Detect hook scripts.
|
|
464
493
|
|
|
@@ -73,7 +73,7 @@ class PracticeExtractor:
|
|
|
73
73
|
configs = []
|
|
74
74
|
for server in getattr(detection, "mcp_servers", []):
|
|
75
75
|
config: dict = {}
|
|
76
|
-
for attr in ("name", "command", "args", "env"):
|
|
76
|
+
for attr in ("name", "command", "args", "env", "pip_package"):
|
|
77
77
|
val = getattr(server, attr, None)
|
|
78
78
|
if val is not None:
|
|
79
79
|
config[attr] = val
|
|
@@ -140,6 +140,7 @@ class PracticeExtractor:
|
|
|
140
140
|
description=f"MCP server: {name}",
|
|
141
141
|
command=config.get("command", ""),
|
|
142
142
|
args=config.get("args", []),
|
|
143
|
+
pip_package=config.get("pip_package"),
|
|
143
144
|
)
|
|
144
145
|
)
|
|
145
146
|
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Pip package validation, detection, and installation utilities.
|
|
2
|
+
|
|
3
|
+
All pip-related logic is isolated here for security audit. Functions validate
|
|
4
|
+
inputs, detect installed packages, resolve commands to pip packages, and
|
|
5
|
+
install packages with comprehensive error handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.metadata
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Allowlist pattern for pip package specs: name, name>=1.0, name[extra]==2.0, etc.
|
|
20
|
+
# Rejects URLs, paths, and shell metacharacters.
|
|
21
|
+
_PIP_SPEC_PATTERN = re.compile(
|
|
22
|
+
r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?" # package name
|
|
23
|
+
r"(\[[A-Za-z0-9,._-]+\])?" # optional extras
|
|
24
|
+
r"([<>=!~]+[A-Za-z0-9.*]+)?" # optional version constraint
|
|
25
|
+
r"$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_DANGEROUS_CHARS = set(";|&$`{}()\n\r")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_pip_spec(spec: str) -> bool:
|
|
32
|
+
"""Validate a pip package specifier against an allowlist.
|
|
33
|
+
|
|
34
|
+
Accepts: 'name', 'name>=1.0', 'name[extra]==2.0'
|
|
35
|
+
Rejects: URLs, file paths, shell metacharacters, empty strings.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
spec: Pip package specifier string.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if the spec is valid and safe.
|
|
42
|
+
"""
|
|
43
|
+
if not spec or not spec.strip():
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
spec = spec.strip()
|
|
47
|
+
|
|
48
|
+
if any(c in spec for c in _DANGEROUS_CHARS):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
if "://" in spec or spec.startswith(("git+", "file:", "/", "\\", ".")):
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
return bool(_PIP_SPEC_PATTERN.match(spec))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _extract_base_name(spec: str) -> str:
|
|
58
|
+
"""Extract the base package name from a pip spec, stripping version/extras."""
|
|
59
|
+
name = re.split(r"[<>=!~\[]", spec.strip())[0]
|
|
60
|
+
return name
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_pip_installed(package_name: str) -> bool:
|
|
64
|
+
"""Check if a pip package is installed.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
package_name: Package name (version constraints are stripped).
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True if the package is installed.
|
|
71
|
+
"""
|
|
72
|
+
base = _extract_base_name(package_name)
|
|
73
|
+
try:
|
|
74
|
+
importlib.metadata.version(base)
|
|
75
|
+
return True
|
|
76
|
+
except importlib.metadata.PackageNotFoundError:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_installed_version(package_name: str) -> Optional[str]:
|
|
81
|
+
"""Get the installed version of a pip package.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
package_name: Package name (version constraints are stripped).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Version string or None if not installed.
|
|
88
|
+
"""
|
|
89
|
+
base = _extract_base_name(package_name)
|
|
90
|
+
try:
|
|
91
|
+
return importlib.metadata.version(base)
|
|
92
|
+
except importlib.metadata.PackageNotFoundError:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def installed_version_satisfies(spec: str) -> bool:
|
|
97
|
+
"""Check if the installed version of a package satisfies the spec.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
spec: Pip package specifier (e.g., 'mcp-server>=1.0').
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if the package is installed and satisfies the version constraint.
|
|
104
|
+
False if not installed or version doesn't satisfy.
|
|
105
|
+
"""
|
|
106
|
+
base = _extract_base_name(spec)
|
|
107
|
+
installed = get_installed_version(base)
|
|
108
|
+
if installed is None:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# Extract version constraint from spec
|
|
112
|
+
constraint_match = re.search(r"([<>=!~]+.+)$", spec.strip())
|
|
113
|
+
if not constraint_match:
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
constraint = constraint_match.group(1)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
from packaging.specifiers import SpecifierSet
|
|
120
|
+
from packaging.version import Version
|
|
121
|
+
|
|
122
|
+
specifier = SpecifierSet(constraint)
|
|
123
|
+
return Version(installed) in specifier
|
|
124
|
+
except ImportError:
|
|
125
|
+
# packaging not available — fall back to simple presence check
|
|
126
|
+
logger.debug("packaging library not available, skipping version constraint check")
|
|
127
|
+
return True
|
|
128
|
+
except Exception:
|
|
129
|
+
# Invalid version/spec — assume satisfied to avoid blocking install
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_pip_package_for_command(command: str, args: list[str]) -> Optional[str]:
|
|
134
|
+
"""Resolve a command/args pair to a pip package name if possible.
|
|
135
|
+
|
|
136
|
+
Patterns detected:
|
|
137
|
+
- command="python" (or python3), args contains "-m", "module_name"
|
|
138
|
+
- command="uvx", first arg is package name
|
|
139
|
+
- command is a console_script entry point
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
command: The executable command.
|
|
143
|
+
args: Command arguments.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Pip package name or None if unrecognized.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
return _resolve_pip_package_for_command_inner(command, args)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.warning("Failed to resolve pip package for command %s: %s", command, e)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _resolve_pip_package_for_command_inner(command: str, args: list[str]) -> Optional[str]:
|
|
156
|
+
"""Inner implementation of resolve_pip_package_for_command."""
|
|
157
|
+
cmd_basename = os.path.basename(command)
|
|
158
|
+
|
|
159
|
+
# Pattern 1: python -m module_name
|
|
160
|
+
if cmd_basename == "python" or cmd_basename == "python3" or re.match(r"python3\.\d+$", cmd_basename):
|
|
161
|
+
if "-m" in args:
|
|
162
|
+
m_idx = args.index("-m")
|
|
163
|
+
if m_idx + 1 < len(args):
|
|
164
|
+
module_name = args[m_idx + 1]
|
|
165
|
+
return _find_distribution_for_module(module_name)
|
|
166
|
+
|
|
167
|
+
# Pattern 2: uvx package_name
|
|
168
|
+
if cmd_basename == "uvx":
|
|
169
|
+
if args:
|
|
170
|
+
pkg_name = args[0]
|
|
171
|
+
if not pkg_name.startswith("-") and validate_pip_spec(pkg_name):
|
|
172
|
+
return pkg_name
|
|
173
|
+
|
|
174
|
+
# Pattern 3: command is a console_script entry point
|
|
175
|
+
dist = _find_distribution_for_script(cmd_basename)
|
|
176
|
+
if dist:
|
|
177
|
+
return dist
|
|
178
|
+
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _find_distribution_for_module(module_name: str) -> Optional[str]:
|
|
183
|
+
"""Find the distribution that provides a given top-level module.
|
|
184
|
+
|
|
185
|
+
Compatible with Python 3.10+ (packages_distributions() is 3.11+).
|
|
186
|
+
"""
|
|
187
|
+
# Try packages_distributions() (3.11+)
|
|
188
|
+
try:
|
|
189
|
+
pkg_dists = importlib.metadata.packages_distributions() # type: ignore[attr-defined]
|
|
190
|
+
dists = pkg_dists.get(module_name)
|
|
191
|
+
if dists:
|
|
192
|
+
return dists[0]
|
|
193
|
+
except AttributeError:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
# Fallback for 3.10: iterate distributions
|
|
197
|
+
for dist in importlib.metadata.distributions():
|
|
198
|
+
top_level = dist.read_text("top_level.txt")
|
|
199
|
+
if top_level:
|
|
200
|
+
modules = [m.strip() for m in top_level.strip().split("\n") if m.strip()]
|
|
201
|
+
if module_name in modules:
|
|
202
|
+
return dist.metadata["Name"]
|
|
203
|
+
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _find_distribution_for_script(script_name: str) -> Optional[str]:
|
|
208
|
+
"""Find the distribution that provides a given console_script entry point.
|
|
209
|
+
|
|
210
|
+
Compatible with Python 3.10+ (entry_points() API varies by version).
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
eps = importlib.metadata.entry_points()
|
|
214
|
+
# Python 3.12+: eps.select()
|
|
215
|
+
if hasattr(eps, "select"):
|
|
216
|
+
console_scripts = eps.select(group="console_scripts") # type: ignore[union-attr]
|
|
217
|
+
elif isinstance(eps, dict):
|
|
218
|
+
# Python 3.10-3.11: eps is a dict
|
|
219
|
+
console_scripts = eps.get("console_scripts", []) # type: ignore[arg-type]
|
|
220
|
+
else:
|
|
221
|
+
console_scripts = []
|
|
222
|
+
|
|
223
|
+
for ep in console_scripts:
|
|
224
|
+
if ep.name == script_name:
|
|
225
|
+
# ep.dist may not exist on all versions
|
|
226
|
+
if hasattr(ep, "dist") and ep.dist is not None:
|
|
227
|
+
return ep.dist.metadata["Name"]
|
|
228
|
+
continue
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def find_pip_executable() -> Optional[str]:
|
|
236
|
+
"""Find a usable way to run pip.
|
|
237
|
+
|
|
238
|
+
Prefers `sys.executable -m pip` (respects current venv),
|
|
239
|
+
falls back to `shutil.which("pip")`.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Python executable path (for `-m pip` usage) or standalone pip path,
|
|
243
|
+
or None if pip is unavailable.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
result = subprocess.run(
|
|
247
|
+
[sys.executable, "-m", "pip", "--version"],
|
|
248
|
+
capture_output=True,
|
|
249
|
+
timeout=10,
|
|
250
|
+
check=False,
|
|
251
|
+
)
|
|
252
|
+
if result.returncode == 0:
|
|
253
|
+
return sys.executable
|
|
254
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
pip_path = shutil.which("pip")
|
|
258
|
+
if pip_path:
|
|
259
|
+
return pip_path
|
|
260
|
+
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def install_pip_package(spec: str, timeout: int = 120) -> tuple[bool, str]:
|
|
265
|
+
"""Install a pip package with comprehensive error handling.
|
|
266
|
+
|
|
267
|
+
Validates the spec first, then runs pip install in a subprocess.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
spec: Pip package specifier (e.g., 'mcp-server-github>=1.0').
|
|
271
|
+
timeout: Maximum seconds to wait for install.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Tuple of (success, message).
|
|
275
|
+
"""
|
|
276
|
+
if not validate_pip_spec(spec):
|
|
277
|
+
return (False, f"Invalid pip package spec: {spec}")
|
|
278
|
+
|
|
279
|
+
pip_exe = find_pip_executable()
|
|
280
|
+
if not pip_exe:
|
|
281
|
+
return (False, "pip is not available. Install pip or use a virtual environment.")
|
|
282
|
+
|
|
283
|
+
# Build command: either `python -m pip install` or `pip install`
|
|
284
|
+
if pip_exe == sys.executable:
|
|
285
|
+
cmd = [sys.executable, "-m", "pip", "install", spec]
|
|
286
|
+
else:
|
|
287
|
+
cmd = [pip_exe, "install", spec]
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
result = subprocess.run(
|
|
291
|
+
cmd,
|
|
292
|
+
capture_output=True,
|
|
293
|
+
text=True,
|
|
294
|
+
timeout=timeout,
|
|
295
|
+
check=False,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if result.returncode == 0:
|
|
299
|
+
return (True, f"Successfully installed {spec}")
|
|
300
|
+
|
|
301
|
+
stderr = result.stderr.lower()
|
|
302
|
+
if "no matching distribution" in stderr:
|
|
303
|
+
return (False, f"Package not found: {spec}")
|
|
304
|
+
if "could not find a version" in stderr:
|
|
305
|
+
return (False, f"No compatible version found for {spec}")
|
|
306
|
+
if "permission denied" in stderr or "permissionerror" in stderr:
|
|
307
|
+
return (False, f"Permission denied installing {spec}. Try using a virtual environment.")
|
|
308
|
+
|
|
309
|
+
return (False, f"pip install failed for {spec} (exit code {result.returncode})")
|
|
310
|
+
|
|
311
|
+
except subprocess.TimeoutExpired:
|
|
312
|
+
return (False, f"pip install timed out after {timeout}s for {spec}")
|
|
313
|
+
except OSError as e:
|
|
314
|
+
return (False, f"Failed to run pip: {e}")
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
|
+
from devsync.core.pip_utils import validate_pip_spec
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
@dataclass
|
|
8
10
|
class CredentialSpec:
|
|
@@ -135,6 +137,7 @@ class MCPDeclaration:
|
|
|
135
137
|
args: list[str] = field(default_factory=list)
|
|
136
138
|
env_vars: dict[str, str] = field(default_factory=dict)
|
|
137
139
|
credentials: list[CredentialSpec] = field(default_factory=list)
|
|
140
|
+
pip_package: Optional[str] = None
|
|
138
141
|
|
|
139
142
|
def __post_init__(self) -> None:
|
|
140
143
|
if not self.name:
|
|
@@ -143,6 +146,9 @@ class MCPDeclaration:
|
|
|
143
146
|
raise ValueError("MCPDeclaration description cannot be empty")
|
|
144
147
|
if self.protocol not in ("stdio", "sse"):
|
|
145
148
|
raise ValueError(f"MCPDeclaration protocol must be 'stdio' or 'sse', got '{self.protocol}'")
|
|
149
|
+
if self.pip_package is not None:
|
|
150
|
+
if not validate_pip_spec(self.pip_package):
|
|
151
|
+
raise ValueError(f"MCPDeclaration pip_package is not a valid pip spec: '{self.pip_package}'")
|
|
146
152
|
|
|
147
153
|
def to_dict(self) -> dict:
|
|
148
154
|
result: dict = {
|
|
@@ -158,6 +164,8 @@ class MCPDeclaration:
|
|
|
158
164
|
result["env_vars"] = self.env_vars
|
|
159
165
|
if self.credentials:
|
|
160
166
|
result["credentials"] = [c.to_dict() for c in self.credentials]
|
|
167
|
+
if self.pip_package is not None:
|
|
168
|
+
result["pip_package"] = self.pip_package
|
|
161
169
|
return result
|
|
162
170
|
|
|
163
171
|
@classmethod
|
|
@@ -171,4 +179,5 @@ class MCPDeclaration:
|
|
|
171
179
|
args=data.get("args", []),
|
|
172
180
|
env_vars=data.get("env_vars", {}),
|
|
173
181
|
credentials=credentials,
|
|
182
|
+
pip_package=data.get("pip_package"),
|
|
174
183
|
)
|
|
@@ -41,6 +41,9 @@ Strip all credential VALUES but keep credential NAMES and descriptions.
|
|
|
41
41
|
Input configuration:
|
|
42
42
|
{mcp_config}
|
|
43
43
|
|
|
44
|
+
If the MCP server command suggests a pip-installable package (e.g., uvx, python -m),
|
|
45
|
+
include the pip_package field with the package name and optional version constraint.
|
|
46
|
+
|
|
44
47
|
Respond with a JSON object:
|
|
45
48
|
{{
|
|
46
49
|
"name": "server-name",
|
|
@@ -55,7 +58,8 @@ Respond with a JSON object:
|
|
|
55
58
|
"description": "what this credential is for",
|
|
56
59
|
"required": true
|
|
57
60
|
}}
|
|
58
|
-
]
|
|
61
|
+
],
|
|
62
|
+
"pip_package": "package-name>=1.0 (if pip-installable, null otherwise)"
|
|
59
63
|
}}"""
|
|
60
64
|
|
|
61
65
|
ADAPT_PRACTICE_PROMPT = """\
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: devsync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Distribute and sync dev tool configurations across teams
|
|
5
5
|
Author-email: Troy Larson <troy@calvinware.com>
|
|
6
6
|
License: MIT License
|
|
@@ -86,6 +86,7 @@ No API key? DevSync works without one -- it falls back to file-copy mode. Add `-
|
|
|
86
86
|
- **AI-powered extraction** -- LLM reads your project's rules, MCP configs, and commands to produce abstract practice declarations
|
|
87
87
|
- **AI-powered installation** -- LLM adapts incoming practices to your existing setup with intelligent merging
|
|
88
88
|
- **23+ AI tool integrations** -- Claude Code, Cursor, Windsurf, GitHub Copilot, Kiro, Roo Code, Cline, Codex, and more
|
|
89
|
+
- **MCP server dependencies** -- auto-detects pip-installable MCP servers and prompts to install them (`--skip-pip` to skip)
|
|
89
90
|
- **MCP credential handling** -- prompts for credentials at install time, never stores them in repos
|
|
90
91
|
- **v1 backward compatibility** -- old `ai-config-kit-package.yaml` packages still install via file-copy
|
|
91
92
|
- **Graceful degradation** -- works without an API key, `--no-ai` flag for explicit file-copy mode
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devsync"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.13.0"
|
|
8
8
|
description = "Distribute and sync dev tool configurations across teams"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "Troy Larson", email = "troy@calvinware.com" }]
|
|
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
|
|
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
|