aparta 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.
- aparta/__init__.py +3 -0
- aparta/agents/__init__.py +23 -0
- aparta/agents/antigravity.py +68 -0
- aparta/agents/base.py +40 -0
- aparta/agents/claude_code.py +53 -0
- aparta/agents/codex.py +50 -0
- aparta/agents/direnv.py +49 -0
- aparta/agents/gemini.py +59 -0
- aparta/backends/__init__.py +0 -0
- aparta/backends/gcloud.py +50 -0
- aparta/backends/gh.py +59 -0
- aparta/backends/git.py +84 -0
- aparta/cli.py +191 -0
- aparta/doctor.py +116 -0
- aparta/fsutil.py +63 -0
- aparta/profiles.py +87 -0
- aparta/wizard.py +262 -0
- aparta-0.1.0.dist-info/METADATA +125 -0
- aparta-0.1.0.dist-info/RECORD +22 -0
- aparta-0.1.0.dist-info/WHEEL +4 -0
- aparta-0.1.0.dist-info/entry_points.txt +2 -0
- aparta-0.1.0.dist-info/licenses/LICENSE +21 -0
aparta/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Adapters de agentes de IA de terminal.
|
|
2
|
+
|
|
3
|
+
Todos os módulos deste pacote são importados automaticamente; qualquer
|
|
4
|
+
subclasse de AgentAdapter com `name` definido entra no REGISTRY sozinha.
|
|
5
|
+
Adicionar um agente novo = criar um arquivo aqui.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import pkgutil
|
|
12
|
+
|
|
13
|
+
from .base import REGISTRY, AgentAdapter
|
|
14
|
+
|
|
15
|
+
for _mod in pkgutil.iter_modules(__path__):
|
|
16
|
+
if _mod.name != "base":
|
|
17
|
+
importlib.import_module(f"{__name__}.{_mod.name}")
|
|
18
|
+
|
|
19
|
+
ADAPTERS: dict[str, type[AgentAdapter]] = REGISTRY
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_adapters(names: list[str]) -> list[AgentAdapter]:
|
|
23
|
+
return [ADAPTERS[n]() for n in names if n in ADAPTERS]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Adapter Antigravity (IDE agent-first do Google).
|
|
2
|
+
|
|
3
|
+
O Antigravity é um fork do VS Code e lê as configurações de workspace em
|
|
4
|
+
.vscode/settings.json; o terminal integrado (usado também pelos agentes ao
|
|
5
|
+
executar comandos) honra "terminal.integrated.env.<plataforma>". Injetamos as
|
|
6
|
+
variáveis em terminal.integrated.env.osx e .linux, preservando o resto.
|
|
7
|
+
|
|
8
|
+
Limitação conhecida: não há (até o momento) um mecanismo documentado do
|
|
9
|
+
Antigravity para injetar env diretamente no processo do agente fora do
|
|
10
|
+
terminal integrado. Se os comandos do agente não herdarem essas variáveis na
|
|
11
|
+
sua versão, combine este adapter com o adapter `direnv` (.envrc) como
|
|
12
|
+
fallback — o direnv aplica o env a qualquer shell que entre na pasta.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from ..fsutil import SafeWriter
|
|
21
|
+
from .base import AgentAdapter
|
|
22
|
+
|
|
23
|
+
_PLATFORM_KEYS = ("terminal.integrated.env.osx", "terminal.integrated.env.linux")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def merge_vscode_settings(existing_text: str, env: dict[str, str]) -> str:
|
|
27
|
+
"""Faz merge do env em terminal.integrated.env.{osx,linux} preservando o resto."""
|
|
28
|
+
data = json.loads(existing_text) if existing_text.strip() else {}
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
raise ValueError(".vscode/settings.json não contém um objeto JSON")
|
|
31
|
+
for key in _PLATFORM_KEYS:
|
|
32
|
+
current = data.get(key, {})
|
|
33
|
+
if not isinstance(current, dict):
|
|
34
|
+
current = {}
|
|
35
|
+
data[key] = {**current, **env}
|
|
36
|
+
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AntigravityAdapter(AgentAdapter):
|
|
40
|
+
name = "antigravity"
|
|
41
|
+
display_name = "Antigravity"
|
|
42
|
+
|
|
43
|
+
def settings_path(self, repo: Path) -> Path:
|
|
44
|
+
return repo / ".vscode" / "settings.json"
|
|
45
|
+
|
|
46
|
+
def detect(self, repo: Path) -> bool:
|
|
47
|
+
# Workspace settings valem para qualquer repo aberto no Antigravity.
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
def inject(self, repo: Path, env: dict[str, str], writer: SafeWriter) -> bool:
|
|
51
|
+
path = self.settings_path(repo)
|
|
52
|
+
existing = path.read_text() if path.exists() else ""
|
|
53
|
+
return writer.write_text(path, merge_vscode_settings(existing, env))
|
|
54
|
+
|
|
55
|
+
def validate(self, repo: Path, env: dict[str, str]) -> tuple[bool, str]:
|
|
56
|
+
path = self.settings_path(repo)
|
|
57
|
+
if not path.exists():
|
|
58
|
+
return False, ".vscode/settings.json ausente"
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(path.read_text())
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
return False, ".vscode/settings.json inválido"
|
|
63
|
+
for key in _PLATFORM_KEYS:
|
|
64
|
+
current = data.get(key, {})
|
|
65
|
+
missing = [k for k, v in env.items() if current.get(k) != v]
|
|
66
|
+
if missing:
|
|
67
|
+
return False, f"{key} divergente: {', '.join(missing)}"
|
|
68
|
+
return True, "env ok"
|
aparta/agents/base.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Interface comum dos adapters de agentes: detect, inject, validate.
|
|
2
|
+
|
|
3
|
+
Registry central: qualquer subclasse concreta de AgentAdapter com `name`
|
|
4
|
+
definido é registrada automaticamente. Adicionar um agente novo = criar um
|
|
5
|
+
arquivo neste pacote (os módulos são importados por aparta.agents).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..fsutil import SafeWriter
|
|
14
|
+
|
|
15
|
+
REGISTRY: dict[str, type["AgentAdapter"]] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentAdapter(ABC):
|
|
19
|
+
"""Um adapter sabe injetar variáveis de ambiente em um agente para um repo."""
|
|
20
|
+
|
|
21
|
+
name: str = ""
|
|
22
|
+
display_name: str = ""
|
|
23
|
+
|
|
24
|
+
def __init_subclass__(cls, **kwargs) -> None:
|
|
25
|
+
super().__init_subclass__(**kwargs)
|
|
26
|
+
if cls.name:
|
|
27
|
+
cls.display_name = cls.display_name or cls.name
|
|
28
|
+
REGISTRY[cls.name] = cls
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def detect(self, repo: Path) -> bool:
|
|
32
|
+
"""True se o agente é usado (ou faz sentido) neste repositório."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def inject(self, repo: Path, env: dict[str, str], writer: SafeWriter) -> bool:
|
|
36
|
+
"""Faz merge das variáveis no arquivo de config do agente. True se mudou algo."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def validate(self, repo: Path, env: dict[str, str]) -> tuple[bool, str]:
|
|
40
|
+
"""(ok, mensagem) — as variáveis esperadas estão presentes?"""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Adapter Claude Code: campo "env" em .claude/settings.local.json (merge, nunca substitui)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..fsutil import SafeWriter
|
|
9
|
+
from .base import AgentAdapter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def merge_settings_env(existing_text: str, env: dict[str, str]) -> str:
|
|
13
|
+
"""Faz merge do objeto env preservando todo o resto do JSON."""
|
|
14
|
+
data = json.loads(existing_text) if existing_text.strip() else {}
|
|
15
|
+
if not isinstance(data, dict):
|
|
16
|
+
raise ValueError("settings.local.json não contém um objeto JSON")
|
|
17
|
+
current_env = data.get("env", {})
|
|
18
|
+
if not isinstance(current_env, dict):
|
|
19
|
+
current_env = {}
|
|
20
|
+
data["env"] = {**current_env, **env}
|
|
21
|
+
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClaudeCodeAdapter(AgentAdapter):
|
|
25
|
+
name = "claude-code"
|
|
26
|
+
display_name = "Claude Code"
|
|
27
|
+
|
|
28
|
+
def settings_path(self, repo: Path) -> Path:
|
|
29
|
+
return repo / ".claude" / "settings.local.json"
|
|
30
|
+
|
|
31
|
+
def detect(self, repo: Path) -> bool:
|
|
32
|
+
# Claude Code funciona em qualquer repo; consideramos sempre aplicável.
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
def inject(self, repo: Path, env: dict[str, str], writer: SafeWriter) -> bool:
|
|
36
|
+
path = self.settings_path(repo)
|
|
37
|
+
existing = path.read_text() if path.exists() else ""
|
|
38
|
+
merged = merge_settings_env(existing, env)
|
|
39
|
+
return writer.write_text(path, merged)
|
|
40
|
+
|
|
41
|
+
def validate(self, repo: Path, env: dict[str, str]) -> tuple[bool, str]:
|
|
42
|
+
path = self.settings_path(repo)
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return False, "settings.local.json ausente"
|
|
45
|
+
try:
|
|
46
|
+
data = json.loads(path.read_text())
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
return False, "settings.local.json inválido"
|
|
49
|
+
current = data.get("env", {})
|
|
50
|
+
missing = [k for k, v in env.items() if current.get(k) != v]
|
|
51
|
+
if missing:
|
|
52
|
+
return False, f"env divergente: {', '.join(missing)}"
|
|
53
|
+
return True, "env ok"
|
aparta/agents/codex.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Adapter Codex: seção [env] em .codex/config.toml do repositório (merge)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import tomli_w
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 11):
|
|
11
|
+
import tomllib
|
|
12
|
+
else: # pragma: no cover
|
|
13
|
+
import tomli as tomllib
|
|
14
|
+
|
|
15
|
+
from ..fsutil import SafeWriter
|
|
16
|
+
from .base import AgentAdapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def merge_codex_env(existing_text: str, env: dict[str, str]) -> str:
|
|
20
|
+
data = tomllib.loads(existing_text) if existing_text.strip() else {}
|
|
21
|
+
current = data.get("env", {})
|
|
22
|
+
if not isinstance(current, dict):
|
|
23
|
+
current = {}
|
|
24
|
+
data["env"] = {**current, **env}
|
|
25
|
+
return tomli_w.dumps(data)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CodexAdapter(AgentAdapter):
|
|
29
|
+
name = "codex"
|
|
30
|
+
display_name = "Codex CLI"
|
|
31
|
+
|
|
32
|
+
def config_path(self, repo: Path) -> Path:
|
|
33
|
+
return repo / ".codex" / "config.toml"
|
|
34
|
+
|
|
35
|
+
def detect(self, repo: Path) -> bool:
|
|
36
|
+
return (repo / ".codex").exists()
|
|
37
|
+
|
|
38
|
+
def inject(self, repo: Path, env: dict[str, str], writer: SafeWriter) -> bool:
|
|
39
|
+
path = self.config_path(repo)
|
|
40
|
+
existing = path.read_text() if path.exists() else ""
|
|
41
|
+
return writer.write_text(path, merge_codex_env(existing, env))
|
|
42
|
+
|
|
43
|
+
def validate(self, repo: Path, env: dict[str, str]) -> tuple[bool, str]:
|
|
44
|
+
path = self.config_path(repo)
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return False, "config.toml ausente"
|
|
47
|
+
data = tomllib.loads(path.read_text())
|
|
48
|
+
current = data.get("env", {})
|
|
49
|
+
missing = [k for k, v in env.items() if current.get(k) != v]
|
|
50
|
+
return (not missing, "env ok" if not missing else f"env divergente: {', '.join(missing)}")
|
aparta/agents/direnv.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Adapter genérico direnv: linhas `export VAR=...` no .envrc (adiciona só se ausentes)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..fsutil import SafeWriter
|
|
9
|
+
from .base import AgentAdapter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def merge_envrc(existing_text: str, env: dict[str, str]) -> str:
|
|
13
|
+
lines = existing_text.splitlines()
|
|
14
|
+
out = list(lines)
|
|
15
|
+
for key, value in env.items():
|
|
16
|
+
pattern = re.compile(rf"^\s*export\s+{re.escape(key)}=")
|
|
17
|
+
replaced = False
|
|
18
|
+
for i, line in enumerate(out):
|
|
19
|
+
if pattern.match(line):
|
|
20
|
+
out[i] = f'export {key}="{value}"'
|
|
21
|
+
replaced = True
|
|
22
|
+
break
|
|
23
|
+
if not replaced:
|
|
24
|
+
out.append(f'export {key}="{value}"')
|
|
25
|
+
return "\n".join(out) + "\n"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DirenvAdapter(AgentAdapter):
|
|
29
|
+
name = "direnv"
|
|
30
|
+
display_name = "direnv (genérico)"
|
|
31
|
+
|
|
32
|
+
def envrc_path(self, repo: Path) -> Path:
|
|
33
|
+
return repo / ".envrc"
|
|
34
|
+
|
|
35
|
+
def detect(self, repo: Path) -> bool:
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
def inject(self, repo: Path, env: dict[str, str], writer: SafeWriter) -> bool:
|
|
39
|
+
path = self.envrc_path(repo)
|
|
40
|
+
existing = path.read_text() if path.exists() else ""
|
|
41
|
+
return writer.write_text(path, merge_envrc(existing, env))
|
|
42
|
+
|
|
43
|
+
def validate(self, repo: Path, env: dict[str, str]) -> tuple[bool, str]:
|
|
44
|
+
path = self.envrc_path(repo)
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return False, ".envrc ausente"
|
|
47
|
+
text = path.read_text()
|
|
48
|
+
missing = [k for k, v in env.items() if f'export {k}="{v}"' not in text]
|
|
49
|
+
return (not missing, "env ok" if not missing else f"faltando: {', '.join(missing)}")
|
aparta/agents/gemini.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Adapter Gemini CLI: variáveis em <repo>/.gemini/.env (mecanismo nativo).
|
|
2
|
+
|
|
3
|
+
O Gemini CLI carrega automaticamente variáveis de ambiente de arquivos .env,
|
|
4
|
+
procurando primeiro <projeto>/.gemini/.env, depois <projeto>/.env, subindo
|
|
5
|
+
até a raiz do projeto (.git) ou a home. Usamos .gemini/.env porque é o local
|
|
6
|
+
específico do Gemini (não interfere no .env da aplicação) e porque algumas
|
|
7
|
+
variáveis só são lidas de lá.
|
|
8
|
+
Ref.: google-gemini/gemini-cli docs/reference/configuration.md
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..fsutil import SafeWriter
|
|
17
|
+
from .base import AgentAdapter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def merge_dotenv(existing_text: str, env: dict[str, str]) -> str:
|
|
21
|
+
"""Atualiza/adiciona linhas KEY="value" preservando o resto do arquivo."""
|
|
22
|
+
lines = existing_text.splitlines()
|
|
23
|
+
out = list(lines)
|
|
24
|
+
for key, value in env.items():
|
|
25
|
+
pattern = re.compile(rf"^\s*(export\s+)?{re.escape(key)}=")
|
|
26
|
+
replaced = False
|
|
27
|
+
for i, line in enumerate(out):
|
|
28
|
+
if pattern.match(line):
|
|
29
|
+
out[i] = f'{key}="{value}"'
|
|
30
|
+
replaced = True
|
|
31
|
+
break
|
|
32
|
+
if not replaced:
|
|
33
|
+
out.append(f'{key}="{value}"')
|
|
34
|
+
return "\n".join(out) + "\n"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GeminiAdapter(AgentAdapter):
|
|
38
|
+
name = "gemini"
|
|
39
|
+
display_name = "Gemini CLI"
|
|
40
|
+
|
|
41
|
+
def env_path(self, repo: Path) -> Path:
|
|
42
|
+
return repo / ".gemini" / ".env"
|
|
43
|
+
|
|
44
|
+
def detect(self, repo: Path) -> bool:
|
|
45
|
+
# O Gemini CLI cria .gemini/ sob demanda; o adapter é sempre aplicável.
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
def inject(self, repo: Path, env: dict[str, str], writer: SafeWriter) -> bool:
|
|
49
|
+
path = self.env_path(repo)
|
|
50
|
+
existing = path.read_text() if path.exists() else ""
|
|
51
|
+
return writer.write_text(path, merge_dotenv(existing, env))
|
|
52
|
+
|
|
53
|
+
def validate(self, repo: Path, env: dict[str, str]) -> tuple[bool, str]:
|
|
54
|
+
path = self.env_path(repo)
|
|
55
|
+
if not path.exists():
|
|
56
|
+
return False, ".gemini/.env ausente"
|
|
57
|
+
text = path.read_text()
|
|
58
|
+
missing = [k for k, v in env.items() if f'{k}="{v}"' not in text]
|
|
59
|
+
return (not missing, "env ok" if not missing else f"faltando: {', '.join(missing)}")
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Backend gcloud: configurations nomeadas selecionadas por CLOUDSDK_ACTIVE_CONFIG_NAME."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from ..fsutil import SafeWriter
|
|
11
|
+
from ..profiles import Profile
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run(args: list[str], config_name: str | None = None) -> subprocess.CompletedProcess:
|
|
17
|
+
env = dict(os.environ)
|
|
18
|
+
if config_name:
|
|
19
|
+
env["CLOUDSDK_ACTIVE_CONFIG_NAME"] = config_name
|
|
20
|
+
return subprocess.run(args, env=env, capture_output=True, text=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def apply_gcloud(profile: Profile, writer: SafeWriter) -> None:
|
|
24
|
+
if not (profile.gcloud_account or profile.gcloud_project):
|
|
25
|
+
return
|
|
26
|
+
name = profile.name
|
|
27
|
+
|
|
28
|
+
cmds: list[tuple[list[str], str | None]] = [
|
|
29
|
+
(["gcloud", "config", "configurations", "create", name, "--no-activate"], None)
|
|
30
|
+
]
|
|
31
|
+
if profile.gcloud_account:
|
|
32
|
+
cmds.append((["gcloud", "config", "set", "account", profile.gcloud_account], name))
|
|
33
|
+
if profile.gcloud_project:
|
|
34
|
+
cmds.append((["gcloud", "config", "set", "project", profile.gcloud_project], name))
|
|
35
|
+
|
|
36
|
+
if writer.dry_run:
|
|
37
|
+
for args, cfg in cmds:
|
|
38
|
+
prefix = f"CLOUDSDK_ACTIVE_CONFIG_NAME={cfg} " if cfg else ""
|
|
39
|
+
console.print(f"[yellow]--dry-run[/yellow] {prefix}{' '.join(args)}")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
create = _run(*cmds[0])
|
|
43
|
+
if create.returncode != 0 and "already exists" not in create.stderr:
|
|
44
|
+
console.print(f"[red]gcloud configurations create falhou:[/red] {create.stderr.strip()}")
|
|
45
|
+
return
|
|
46
|
+
for args, cfg in cmds[1:]:
|
|
47
|
+
r = _run(args, cfg)
|
|
48
|
+
if r.returncode != 0:
|
|
49
|
+
console.print(f"[red]{' '.join(args)} falhou:[/red] {r.stderr.strip()}")
|
|
50
|
+
console.print(f"[green]gcloud:[/green] configuração '{name}' pronta")
|
aparta/backends/gh.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Backend GitHub CLI: diretório de config paralelo ~/.config/gh-<contexto>.
|
|
2
|
+
|
|
3
|
+
Os tokens ficam no keyring do macOS, então copiar ~/.config/gh e trocar o
|
|
4
|
+
usuário ativo com GH_CONFIG_DIR apontando para a cópia funciona sem novo login.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from ..fsutil import SafeWriter
|
|
16
|
+
from ..profiles import Profile
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def apply_gh(profile: Profile, writer: SafeWriter, home: Path | None = None) -> None:
|
|
22
|
+
if not profile.gh_user:
|
|
23
|
+
return
|
|
24
|
+
home = home or Path.home()
|
|
25
|
+
src = home / ".config" / "gh"
|
|
26
|
+
dst = home / ".config" / f"gh-{profile.name}"
|
|
27
|
+
|
|
28
|
+
if not src.exists():
|
|
29
|
+
console.print("[yellow]aviso:[/yellow] ~/.config/gh não existe — rode `gh auth login` antes.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
if writer.dry_run:
|
|
33
|
+
if not dst.exists():
|
|
34
|
+
console.print(f"[yellow]--dry-run[/yellow] copiaria {src} -> {dst}")
|
|
35
|
+
console.print(
|
|
36
|
+
f"[yellow]--dry-run[/yellow] GH_CONFIG_DIR={dst} gh auth switch --user {profile.gh_user}"
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if not dst.exists():
|
|
41
|
+
shutil.copytree(src, dst)
|
|
42
|
+
console.print(f"[green]criado:[/green] {dst}")
|
|
43
|
+
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
["gh", "auth", "switch", "--user", profile.gh_user],
|
|
46
|
+
env={"GH_CONFIG_DIR": str(dst), "PATH": _path_env()},
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
)
|
|
50
|
+
if result.returncode != 0:
|
|
51
|
+
console.print(f"[red]gh auth switch falhou:[/red] {result.stderr.strip()}")
|
|
52
|
+
else:
|
|
53
|
+
console.print(f"[green]gh:[/green] usuário ativo em {dst.name}: {profile.gh_user}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _path_env() -> str:
|
|
57
|
+
import os
|
|
58
|
+
|
|
59
|
+
return os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin")
|
aparta/backends/git.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Backend git: gitconfig por contexto + includeIf no ~/.gitconfig (merge, nunca substitui)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..fsutil import SafeWriter
|
|
9
|
+
from ..profiles import Profile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def context_gitconfig_path(profile: Profile, home: Path) -> Path:
|
|
13
|
+
return home / f".gitconfig-{profile.name}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_context_gitconfig(profile: Profile) -> str:
|
|
17
|
+
"""Gera o conteúdo de ~/.gitconfig-<contexto>."""
|
|
18
|
+
lines = ["# Gerado pelo aparta — perfil: " + profile.name, "[user]"]
|
|
19
|
+
if profile.git_name:
|
|
20
|
+
lines.append(f"\tname = {profile.git_name}")
|
|
21
|
+
lines.append(f"\temail = {profile.git_email}")
|
|
22
|
+
if profile.ssh_key:
|
|
23
|
+
key = profile.ssh_key
|
|
24
|
+
lines += [
|
|
25
|
+
"[core]",
|
|
26
|
+
f'\tsshCommand = ssh -i {key} -o IdentitiesOnly=yes',
|
|
27
|
+
]
|
|
28
|
+
if profile.ssh_alias:
|
|
29
|
+
lines += [
|
|
30
|
+
f'[url "git@{profile.ssh_alias}:"]',
|
|
31
|
+
"\tinsteadOf = https://github.com/",
|
|
32
|
+
"\tinsteadOf = git@github.com:",
|
|
33
|
+
]
|
|
34
|
+
return "\n".join(lines) + "\n"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def gitdir_pattern(profile: Profile) -> str:
|
|
38
|
+
"""Padrão gitdir com ~ preservado quando a raiz está sob a home."""
|
|
39
|
+
root = str(profile.root_path)
|
|
40
|
+
home = str(Path.home())
|
|
41
|
+
if root.startswith(home):
|
|
42
|
+
root = "~" + root[len(home):]
|
|
43
|
+
if not root.endswith("/"):
|
|
44
|
+
root += "/"
|
|
45
|
+
return root
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render_includeif_block(gitdir: str, include_path: str) -> str:
|
|
49
|
+
return f'[includeIf "gitdir:{gitdir}"]\n\tpath = {include_path}\n'
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def has_includeif(gitconfig_text: str, gitdir: str) -> bool:
|
|
53
|
+
"""True se já existe um bloco includeIf para este gitdir."""
|
|
54
|
+
pattern = re.compile(
|
|
55
|
+
r'\[includeIf\s+"gitdir:' + re.escape(gitdir) + r'"\]', re.IGNORECASE
|
|
56
|
+
)
|
|
57
|
+
return bool(pattern.search(gitconfig_text))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def merge_includeif(gitconfig_text: str, gitdir: str, include_path: str) -> str:
|
|
61
|
+
"""Adiciona o bloco includeIf apenas se ausente; preserva todo o resto."""
|
|
62
|
+
if has_includeif(gitconfig_text, gitdir):
|
|
63
|
+
return gitconfig_text
|
|
64
|
+
block = render_includeif_block(gitdir, include_path)
|
|
65
|
+
if gitconfig_text and not gitconfig_text.endswith("\n"):
|
|
66
|
+
gitconfig_text += "\n"
|
|
67
|
+
sep = "\n" if gitconfig_text else ""
|
|
68
|
+
return gitconfig_text + sep + block
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def apply_git(profile: Profile, writer: SafeWriter, home: Path | None = None) -> None:
|
|
72
|
+
"""Escreve ~/.gitconfig-<contexto> e faz merge do includeIf no ~/.gitconfig."""
|
|
73
|
+
home = home or Path.home()
|
|
74
|
+
ctx_path = context_gitconfig_path(profile, home)
|
|
75
|
+
writer.write_text(ctx_path, render_context_gitconfig(profile))
|
|
76
|
+
|
|
77
|
+
gitconfig = home / ".gitconfig"
|
|
78
|
+
existing = gitconfig.read_text() if gitconfig.exists() else ""
|
|
79
|
+
include_path = str(ctx_path)
|
|
80
|
+
if include_path.startswith(str(home)):
|
|
81
|
+
include_path = "~" + include_path[len(str(home)):]
|
|
82
|
+
merged = merge_includeif(existing, gitdir_pattern(profile), include_path)
|
|
83
|
+
if merged != existing:
|
|
84
|
+
writer.write_text(gitconfig, merged)
|