Move the four providers onto _runtime and _shell_command; expected runtime scripts take the runtime config; add unique_preconditions. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
222 lines
6.8 KiB
Python
222 lines
6.8 KiB
Python
"""GitHub Copilot CLI source discovery and managed runtime validation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import os
|
|
from typing import List, Optional, Tuple
|
|
|
|
from ._jsonc import parse_jsonc
|
|
from ._managed_files import (
|
|
FileSnapshot,
|
|
MAX_SOURCE_BYTES,
|
|
physical_directory_exists,
|
|
read_physical_file,
|
|
)
|
|
from ._runtime import expected_runtime_scripts, runtime_contract_exists
|
|
from ._strict_json import JsonObject, parse_json_object
|
|
from .copilot_contract import (
|
|
AGENT_KEY,
|
|
CONFIG_FILE,
|
|
CopilotDocument,
|
|
CopilotSources,
|
|
RuntimeContract,
|
|
SourcePrecondition,
|
|
create_document,
|
|
parse_document,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SettingsLayer:
|
|
file_path: str
|
|
label: str
|
|
jsonc: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CopilotPaths:
|
|
copilot_home: str
|
|
user_hooks_directory: str
|
|
user_hook_path: str
|
|
legacy_hook_path: str
|
|
settings_layers: Tuple[SettingsLayer, ...]
|
|
inspected_directories: Tuple[Tuple[str, str], ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ParsedSettingsLayer:
|
|
layer: SettingsLayer
|
|
disable_all_hooks: Optional[bool]
|
|
snapshot: Optional[FileSnapshot]
|
|
|
|
|
|
def resolve_copilot_paths(home_dir: Optional[str] = None) -> CopilotPaths:
|
|
home = home_dir if home_dir is not None else os.path.expanduser("~")
|
|
override = os.environ.get("COPILOT_HOME")
|
|
copilot_home = override if override else os.path.join(home, ".copilot")
|
|
project = os.getcwd()
|
|
github = os.path.join(project, ".github")
|
|
github_copilot = os.path.join(github, "copilot")
|
|
github_hooks = os.path.join(github, "hooks")
|
|
claude = os.path.join(project, ".claude")
|
|
user_hooks = os.path.join(copilot_home, "hooks")
|
|
return CopilotPaths(
|
|
copilot_home=copilot_home,
|
|
user_hooks_directory=user_hooks,
|
|
user_hook_path=os.path.join(user_hooks, CONFIG_FILE),
|
|
legacy_hook_path=os.path.join(github_hooks, "hooks.json"),
|
|
settings_layers=(
|
|
SettingsLayer(
|
|
os.path.join(copilot_home, "config.json"),
|
|
"legacy Copilot user config",
|
|
False,
|
|
),
|
|
SettingsLayer(
|
|
os.path.join(copilot_home, "settings.json"),
|
|
"Copilot user settings",
|
|
True,
|
|
),
|
|
SettingsLayer(
|
|
os.path.join(claude, "settings.json"),
|
|
"Claude repository settings",
|
|
True,
|
|
),
|
|
SettingsLayer(
|
|
os.path.join(claude, "settings.local.json"),
|
|
"Claude local settings",
|
|
True,
|
|
),
|
|
SettingsLayer(
|
|
os.path.join(github_copilot, "settings.json"),
|
|
"Copilot repository settings",
|
|
True,
|
|
),
|
|
SettingsLayer(
|
|
os.path.join(github_copilot, "settings.local.json"),
|
|
"Copilot local settings",
|
|
True,
|
|
),
|
|
),
|
|
inspected_directories=(
|
|
(project, "Copilot working directory"),
|
|
(copilot_home, "COPILOT_HOME"),
|
|
(user_hooks, "Copilot user hooks directory"),
|
|
(github, "GitHub configuration directory"),
|
|
(github_hooks, "GitHub repository hooks directory"),
|
|
(github_copilot, "Copilot repository settings directory"),
|
|
(claude, "Claude repository settings directory"),
|
|
),
|
|
)
|
|
|
|
|
|
def _inspect_directories(locations: Tuple[Tuple[str, str], ...]) -> None:
|
|
for directory, label in locations:
|
|
physical_directory_exists(directory, label)
|
|
|
|
|
|
def _read_hook_document(
|
|
file_path: str, label: str
|
|
) -> Optional[CopilotDocument]:
|
|
snapshot = read_physical_file(file_path, label, MAX_SOURCE_BYTES)
|
|
return None if snapshot is None else parse_document(file_path, snapshot, label)
|
|
|
|
|
|
def _parse_settings(raw: str, layer: SettingsLayer) -> JsonObject:
|
|
if not raw.strip():
|
|
return {}
|
|
label = f"{layer.label} at {layer.file_path}"
|
|
value = (
|
|
parse_jsonc(raw, label, allow_trailing_commas=True)
|
|
if layer.jsonc
|
|
else parse_json_object(raw, label)
|
|
)
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{label} must contain a JSON object")
|
|
return value
|
|
|
|
|
|
def _read_settings_layer(layer: SettingsLayer) -> _ParsedSettingsLayer:
|
|
snapshot = read_physical_file(
|
|
layer.file_path, layer.label, MAX_SOURCE_BYTES
|
|
)
|
|
if snapshot is None:
|
|
return _ParsedSettingsLayer(layer, None, None)
|
|
root = _parse_settings(snapshot.contents, layer)
|
|
disabled = root.get("disableAllHooks")
|
|
if "disableAllHooks" in root and not isinstance(disabled, bool):
|
|
raise ValueError(
|
|
f'{layer.label} at {layer.file_path} field "disableAllHooks" '
|
|
"must be a boolean"
|
|
)
|
|
return _ParsedSettingsLayer(layer, disabled, snapshot)
|
|
|
|
|
|
def _effective_disabled_source(
|
|
layers: List[_ParsedSettingsLayer],
|
|
) -> Optional[str]:
|
|
disabled_by: Optional[str] = None
|
|
for item in layers:
|
|
source = f"{item.layer.label} at {item.layer.file_path}"
|
|
if item.disable_all_hooks is True:
|
|
disabled_by = source
|
|
elif item.disable_all_hooks is False:
|
|
disabled_by = None
|
|
return disabled_by
|
|
|
|
|
|
def read_sources(home_dir: Optional[str] = None) -> CopilotSources:
|
|
paths = resolve_copilot_paths(home_dir)
|
|
_inspect_directories(paths.inspected_directories)
|
|
user = _read_hook_document(
|
|
paths.user_hook_path, "GitHub Copilot user hooks"
|
|
)
|
|
legacy = _read_hook_document(
|
|
paths.legacy_hook_path, "GitHub Copilot legacy project hooks"
|
|
)
|
|
layers = [_read_settings_layer(layer) for layer in paths.settings_layers]
|
|
user_document = user or create_document(paths.user_hook_path)
|
|
disabled_by = (
|
|
f"GitHub Copilot user hooks at {paths.user_hook_path}"
|
|
if user_document.hooks_disabled
|
|
else _effective_disabled_source(layers)
|
|
)
|
|
preconditions = tuple(
|
|
SourcePrecondition(
|
|
item.layer.file_path,
|
|
item.layer.label,
|
|
item.snapshot,
|
|
)
|
|
for item in layers
|
|
)
|
|
return CopilotSources(
|
|
user_document,
|
|
legacy,
|
|
disabled_by,
|
|
preconditions,
|
|
)
|
|
|
|
|
|
def require_hooks_enabled(sources: CopilotSources) -> None:
|
|
if sources.disabled_by:
|
|
raise ValueError(
|
|
"GitHub Copilot hooks are disabled by "
|
|
f"{sources.disabled_by}; set disableAllHooks to false before installation"
|
|
)
|
|
|
|
|
|
def _expected_scripts(agent_id: str, _config: JsonObject) -> Tuple[str, str]:
|
|
return expected_runtime_scripts(AGENT_KEY, agent_id)
|
|
|
|
|
|
def runtime_files_exist(contracts: List[RuntimeContract]) -> bool:
|
|
return any(
|
|
runtime_contract_exists(
|
|
contract,
|
|
AGENT_KEY,
|
|
"Copilot",
|
|
_expected_scripts,
|
|
)
|
|
for contract in contracts
|
|
)
|