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 ADDED
@@ -0,0 +1,3 @@
1
+ """aparta — isolamento de contas de desenvolvimento por pasta de projeto."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)}")
@@ -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)}")
@@ -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)