Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/copilotsetup/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class CopilotSetupApp(App[None]):
Binding("u", "tab_action('u')", "Upgrade", show=False),
Binding("m", "tab_action('m')", "Marketplace", show=False),
Binding("h", "tab_action('h')", "Health", show=False),
Binding("j", "tab_action('j')", "JSON", show=False),
]

def compose(self) -> ComposeResult:
Expand Down
6 changes: 4 additions & 2 deletions src/copilotsetup/data/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import json
from dataclasses import dataclass
from dataclasses import dataclass, field

from copilotsetup.config import extensions_dir

Expand All @@ -15,6 +15,7 @@ class ExtensionInfo:
name: str
path: str = ""
version: str = ""
raw_data: dict = field(default_factory=dict, hash=False, compare=False)


class ExtensionProvider:
Expand All @@ -29,12 +30,13 @@ def load(self) -> list[ExtensionInfo]:
if not entry.is_dir():
continue
version = ""
data: dict = {}
pkg_json = entry / "package.json"
if pkg_json.is_file():
try:
data = json.loads(pkg_json.read_text(encoding="utf-8"))
version = str(data.get("version", "") or "")
except (json.JSONDecodeError, OSError):
pass
result.append(ExtensionInfo(name=entry.name, path=str(entry), version=version))
result.append(ExtensionInfo(name=entry.name, path=str(entry), version=version, raw_data=data))
return result
5 changes: 3 additions & 2 deletions src/copilotsetup/data/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import logging
from dataclasses import dataclass
from dataclasses import dataclass, field

from copilotsetup.config import config_json
from copilotsetup.utils.file_io import read_json
Expand All @@ -18,6 +18,7 @@ class HookInfo:
event: str
command: str
hook_type: str = "command"
raw_data: dict = field(default_factory=dict, hash=False, compare=False)


class HookProvider:
Expand All @@ -40,5 +41,5 @@ def load(self) -> list[HookInfo]:
command = entry.get("command", "")
if not command:
continue
result.append(HookInfo(event=str(event_name), command=str(command)))
result.append(HookInfo(event=str(event_name), command=str(command), raw_data=dict(entry)))
return result
4 changes: 3 additions & 1 deletion src/copilotsetup/data/lsp_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field

from copilotsetup.config import lsp_config_json
from copilotsetup.platform_ops import validate_lsp_binary
Expand All @@ -17,6 +17,7 @@ class LspInfo:
command: str
args: tuple[str, ...] = ()
binary_ok: bool = False
raw_data: dict = field(default_factory=dict, hash=False, compare=False)

@property
def status(self) -> str:
Expand Down Expand Up @@ -52,6 +53,7 @@ def load(self) -> list[LspInfo]:
command=command,
args=tuple(str(a) for a in args),
binary_ok=binary_ok,
raw_data=dict(entry),
)
)
return result
20 changes: 19 additions & 1 deletion src/copilotsetup/data/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import json
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path

from copilotsetup.config import config_json, installed_plugins_dir
Expand Down Expand Up @@ -31,6 +31,8 @@ class PluginInfo:

name: str
source: str = ""
source_type: str = ""
source_repo: str = ""
version: str = ""
installed: bool = False
disabled: bool = False
Expand All @@ -43,6 +45,11 @@ class PluginInfo:
upgrade_summary: str = ""
upgrade_version: str = ""
upgrade_provisional: bool = False
dev_summary: str = ""
dev_branch: str = ""
dev_commits_ahead: int = 0
latest_release: str = ""
raw_data: dict = field(default_factory=dict, hash=False, compare=False)

@property
def status(self) -> str:
Expand Down Expand Up @@ -124,10 +131,20 @@ def load(self) -> list[PluginInfo]:
if f.is_file() and f.name.endswith(".agent.md")
]

# Extract source info from config.json source object
source_obj = entry.get("source")
source_type = ""
source_repo = ""
if isinstance(source_obj, dict):
source_type = str(source_obj.get("source", ""))
source_repo = str(source_obj.get("repo", "") or source_obj.get("path", ""))

result.append(
PluginInfo(
name=str(name),
source=marketplace or "local",
source_type=source_type,
source_repo=source_repo,
version=version,
installed=True,
disabled=not enabled,
Expand All @@ -136,6 +153,7 @@ def load(self) -> list[PluginInfo]:
bundled_skills=tuple(bundled_skills),
bundled_servers=tuple(bundled_servers),
bundled_agents=tuple(bundled_agents),
raw_data=dict(entry),
)
)
return result
Expand Down
6 changes: 5 additions & 1 deletion src/copilotsetup/data/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import json
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field

from copilotsetup.config import config_json
from copilotsetup.utils.file_io import read_json
Expand Down Expand Up @@ -37,6 +37,7 @@ class SettingInfo:
display_name: str
value: str
value_type: str = "string"
raw_data: dict = field(default_factory=dict, hash=False, compare=False)

@property
def status(self) -> str:
Expand Down Expand Up @@ -96,6 +97,7 @@ def load(self) -> list[SettingInfo]:
display_name=flat_key,
value=_format_value(sub_val),
value_type=_value_type_for(sub_val),
raw_data={flat_key: sub_val},
)
)
elif isinstance(val, list):
Expand All @@ -105,6 +107,7 @@ def load(self) -> list[SettingInfo]:
display_name=key,
value=_format_value(val),
value_type="list",
raw_data={key: val},
)
)
elif isinstance(val, (str, bool, int, float)):
Expand All @@ -114,6 +117,7 @@ def load(self) -> list[SettingInfo]:
display_name=key,
value=str(val),
value_type=_value_type_for(val),
raw_data={key: val},
)
)
return result
116 changes: 96 additions & 20 deletions src/copilotsetup/plugin_upgrades.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@
(e.g. ``v0.11.2``). Detection compares the current tag against the highest
semver tag on ``origin``.

Local plugins living on a regular branch are also supported: the nearest
ancestor tag (via ``git describe --tags --abbrev=0``) is used as the current
version. A ``config_version`` fallback covers repos with no local tags.
Plugins installed from a local source path that is a git checkout on a
*branch* (active dev work) are reported as ``STATUS_LOCAL_DEV`` instead of
falsely claiming they can be upgraded. ``copilot plugin update`` only does
``git pull`` on the current branch, so an arrow that implies "click to
upgrade to vX.Y.Z" would be misleading — the user manages their own checkout.

A ``config_version`` fallback (used when no git tag describes HEAD and we
have no branch info) covers older installs whose package.json version is
the only signal.
"""

from __future__ import annotations
Expand All @@ -29,6 +35,10 @@
STATUS_NO_UPSTREAM = "no-upstream"
STATUS_NO_PATH = "no-path"
STATUS_ERROR = "error"
# HEAD is on a branch (not detached on a tag); user is doing local dev work.
# We do not present this as upgradable because ``copilot plugin update`` would
# only ``git pull`` the branch — it cannot meaningfully "upgrade to vX.Y.Z".
STATUS_LOCAL_DEV = "local-dev"


@dataclass
Expand All @@ -42,6 +52,8 @@ class PluginUpgradeInfo:
current_version: str = ""
latest_version: str = ""
network_verified: bool = False
dev_branch: str = ""
dev_commits_ahead: int = 0

@property
def upgrade_available(self) -> bool:
Expand All @@ -51,6 +63,13 @@ def upgrade_available(self) -> bool:
def summary(self) -> str:
return f"↑ {self.latest_version}" if self.upgrade_available else ""

@property
def dev_summary(self) -> str:
"""Human-readable dev state, e.g. ``dev: feat/x`` or empty when not on a branch."""
if self.status != STATUS_LOCAL_DEV or not self.dev_branch:
return ""
return f"dev: {self.dev_branch}"


def _git_env(*, gh_token_timeout: float = 5.0) -> dict[str, str]:
"""Build a non-interactive, auth-aware git environment."""
Expand Down Expand Up @@ -120,22 +139,41 @@ def _parse_semver(tag: str) -> tuple[int, int, int] | None:
return int(match.group(1)), int(match.group(2)), int(match.group(3))


def _get_current_tag(path: Path) -> str | None:
"""Return the version tag for HEAD.

Tries ``--exact-match`` first (detached-HEAD installs), then falls back to
``--abbrev=0`` which finds the nearest ancestor tag (branch-based repos).
"""
def _get_exact_tag(path: Path) -> str | None:
"""Return the tag at HEAD, or None if HEAD is not on an exact tag."""
result = _run_git(["describe", "--tags", "--exact-match", "HEAD"], path, timeout=5.0)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
# Fallback: nearest ancestor tag (works when HEAD is ahead of a tag)
return None


def _get_ancestor_tag(path: Path) -> str | None:
"""Return the nearest ancestor tag for HEAD (no exact-match required)."""
result = _run_git(["describe", "--tags", "--abbrev=0", "HEAD"], path, timeout=5.0)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return None


def _get_head_branch(path: Path) -> str | None:
"""Return the branch HEAD is on, or None if HEAD is detached."""
result = _run_git(["symbolic-ref", "--quiet", "--short", "HEAD"], path, timeout=5.0)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return None


def _get_commits_ahead(path: Path, base_ref: str) -> int:
"""Return the number of commits HEAD is ahead of ``base_ref``, or 0 on error."""
result = _run_git(["rev-list", "--count", f"{base_ref}..HEAD"], path, timeout=5.0)
if result.returncode != 0:
return 0
try:
return int(result.stdout.strip())
except ValueError:
return 0


def _list_remote_tags(path: Path) -> list[str]:
result = _run_git(["ls-remote", "--tags", "origin"], path, timeout=10.0)
if result.returncode != 0:
Expand Down Expand Up @@ -197,22 +235,32 @@ def check_plugin(
info.detail = "not a git checkout"
return info

# Detect current version: exact tag → nearest ancestor tag → config.json
current = _get_current_tag(path)
if current is None and config_version:
# Synthesize a tag-like string so semver comparison works
# Determine HEAD state. Branch is authoritative: if HEAD is on a branch,
# we treat it as a dev install regardless of whether HEAD happens to also
# be at an exact tag — because ``copilot plugin update`` will ``git pull``
# the branch, not check out the tag, so we cannot promise tag-based
# upgrades land. Only a detached HEAD on an exact tag uses the upgrade
# flow.
branch = _get_head_branch(path)
exact_tag = _get_exact_tag(path) if branch is None else None

if exact_tag is not None:
current: str | None = exact_tag
elif branch is None and config_version:
# Detached HEAD with no exact tag — use config_version as a best-effort
# current version so we can still compare against origin tags.
v = config_version.strip()
if _parse_semver(v) is not None:
current = v
elif _parse_semver(f"v{v}") is not None:
current = f"v{v}"
if current is None:
info.status = STATUS_NO_UPSTREAM
info.detail = "HEAD is not on a version tag"
return info

info.current_version = current
else:
current = None
else:
current = None

# Always probe remote — even in dev mode, latest_version provides useful
# context ("origin has v2.0.2") for the detail pane.
fetch_failed = False
if _cached_latest is not None:
remote_tags = [_cached_latest]
Expand All @@ -231,6 +279,34 @@ def check_plugin(
info.network_verified = True

latest = _highest_semver_tag(remote_tags) if remote_tags else None

# Branch mode → STATUS_LOCAL_DEV. We surface latest_version (when known) so
# the detail pane can show "origin has vX.Y.Z" but the table never claims
# this is a one-click upgrade.
if branch is not None:
info.status = STATUS_LOCAL_DEV
info.dev_branch = branch
ancestor = _get_ancestor_tag(path)
if ancestor:
info.current_version = ancestor
info.dev_commits_ahead = _get_commits_ahead(path, ancestor)
if latest is not None:
info.latest_version = latest
if ancestor and info.dev_commits_ahead:
info.detail = f"branch {branch} ({info.dev_commits_ahead} past {ancestor})"
elif ancestor:
info.detail = f"branch {branch} (at {ancestor})"
else:
info.detail = f"branch {branch} (no ancestor tag)"
return info

if current is None:
info.status = STATUS_NO_UPSTREAM
info.detail = "HEAD is not on a version tag"
return info

info.current_version = current

if latest is None:
info.status = STATUS_NO_UPSTREAM
info.detail = "no semver tags on origin"
Expand Down
Loading
Loading