From 48b6cda9d4c36f7cd9e42afced237085506b2606 Mon Sep 17 00:00:00 2001 From: Dinesh Veluswamy <79753351+thatcatfromspace@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:39:10 +0530 Subject: [PATCH] =?UTF-8?q?Discover=20Augment=20Code=20(Auggie=20CLI=20+?= =?UTF-8?q?=20VS=20Code=20+=20JetBrains)=20=E2=80=94=20WEB-4950=20(#213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(augment): discover Augment Code across Auggie CLI, VS Code, JetBrains Add Augment Code (label "augment") to the coding-discovery scanner as a single cross-surface detector returning per-surface rows (Auggie CLI, Augment (VS Code), Augment ()) that share one ~/.augment config. Mirrors the GitHub Copilot cross-surface pattern. Extraction parity with claude_code/copilot_cli: - settings/permissions: toolPermissions -> allow/deny/ask; hooks preserved in raw_settings (user/managed/project/local scopes) - MCP servers: top-level mcpServers + augment.advanced.mcpServers + flat form - rules/guidelines: .augment-guidelines, .augment/rules/*.{md,mdx}, ~/.augment/rules, ~/.augment/user-guidelines.md, hierarchical AGENTS.md/CLAUDE.md - skills/commands: ~/.augment/skills//SKILL.md, .augment/commands/*.md Shared ~/.augment config is attached to a single canonical surface (Auggie CLI > VS Code > JetBrains) to avoid duplication; non-canonical surfaces emit bare detection rows. macOS holds the logic; Windows/Linux are thin OS-seam subclasses. Part of WEB-4950 (discovery half). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(augment): address review findings (cross-user skills, rules dup, settings scope) - Owner-key user-scope skills by file_path so a user's skill content can't leak onto another user's row under a root all-users scan (mirrors _copilot_skill_owner_home) - Guard the rules project walk against re-collecting ~/.augment/rules as project scope, matching the settings/skills extractors' user-dir guards - Settings: extract user + managed only; drop the unsurfaceable project/local filesystem walk; include managed scope in the permissions filter - Skip symlinked dirs before the .augment branch in the rules walk - Memo parity for _get_augment_mcp; settings docstring fix - Add regression tests for the cross-user skills leak and rules duplication Co-Authored-By: Claude Opus 4.8 (1M context) * fix(augment tests): pin _filesystem_root in project-walk tests for Windows The rules/skills project-walk tests instantiate the macOS extractor and patched only _iter_top_level_dirs, leaving _filesystem_root at "/". On Windows the walk's item.relative_to("/") raises ValueError for a C:\ temp path, so every subdirectory was skipped and project rules/skills were never collected (8 Windows-only unittest failures, all in the rules/skills suites). Pin _filesystem_root to the temp ancestor so relative_to works cross-platform. Production is unaffected: real Windows uses the Windows*AugmentExtractor with the correct filesystem root. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(augment): address PR-bot findings (per-user canonical, MCP memo, dedup, symlink, ownership) Cursor + Greptile inline review follow-ups: - G: canonical Augment surface is now chosen PER USER (keyed by _config_path), not a single global name. A root multi-user scan picks a winner for each user's ~/.augment independently, so a VS Code-only user no longer gets a bare row that drops their config when another user has the CLI. - F: managed-scope-only permissions (org-wide /etc/augment) no longer manufacture a phantom Augment row for a non-owner under root scans (_augment_owned_by_user no longer counts managed permissions as user-owned data). - B: MCP accessor memoization uses a distinct UNSET sentinel so a legitimate cached None (no MCP configured) short-circuits instead of re-running the full MCP walk on every surface. - C: emit at most one "Augment (VS Code)" row per user (prefer stable over nightly) so stable+nightly installs don't create duplicate canonical rows. - A: skills walk skips symlinked dirs before the .augment handling (mirrors the rules/mcp/settings walks) so a symlinked .augment can't be followed. - E: Windows skills walk also skips other-tool config dirs (parity with the macOS base + Windows rules walk). D (Linux rules per-user error guard) was already covered by the macOS base _extract_user_rules try/except that the Linux subclass inherits. Adds per-user-canonical, managed-ownership, MCP-memo, dual-extension, and symlink regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(augment): attribute JetBrains rows to the IDE owner under root scans (H2) Under a root MDM all-users scan, MacOSJetBrainsDetector returns every user's IDEs, but the Augment JetBrains row stamped _config_path from the outer scan home — so an IDE owned by user B could be attributed to user A's ~/.augment (wrong permissions/config). Run JetBrains detection once and derive each IDE's owning user from the IDE's own config path (longest-prefix match against the scanned homes), falling back to the scoped/current home. This also removes the prior N-times-redundant rescan. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(augment tests): make H2 JetBrains-owner assertion separator-agnostic The new H2 regression test hardcoded "/Users/bob/.augment", but production stringifies a Path, so on Windows _config_path is "\\Users\\bob\\.augment" and the literal-POSIX assertion failed (Windows CI). Build the expected value via Path so the separator matches the host OS. Test-only; production is correct. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(augment): guard each user in Linux rules _scan_all_user_homes (Greptile) Parity with LinuxAugmentSkillsExtractor: wrap each per-user extract_for_user in try/except (PermissionError, OSError) so one unreadable home can't abort the whole multi-user rules scan. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(augment): stop duplicate MCP servers from the workspace walk (Greptile) The MCP workspace walk descended into user homes and re-read ~/.augment/settings.json as PROJECT scope — the same servers already collected as USER scope — emitting duplicate MCP servers under two project paths (~/.augment as "user" + the home dir as "project"). Record each user-home ~/.augment collected as user scope and skip it in the workspace walk, matching the rules/settings/skills user-dir guards. Genuine project .augment dirs are still collected. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(augment): collect .claude/.agents skills; group user skills by config dir Two follow-ups from dogfooding the Auggie CLI: 1. Auggie loads skills/commands from .augment, .claude AND .agents (per docs.augmentcode.com/cli/skills, in both workspace and home) and honors .claude/commands for Claude compatibility. The Augment skills extractor now sources all three marker dirs (user + project). The same .claude/.agents item is reported under Claude Code / Copilot CLI AND Augment by design — each tool reports what it loads; the backend dedups per (tool, home_user). 2. Group user-scope skills under their CONFIG DIR (~/.augment, ~/.claude, ~/.agents) instead of the bare home. The backend keys an AIToolProject per project path, so the old bare-home key surfaced a spurious "~" project separate from the ~/.augment rules/MCP project; now ~/.augment skills coalesce with that row's rules/MCP, while ~/.claude/~/.agents skills group under their own dir. Still owner-scoped (home is in the path) so the per-user filter prevents cross-user skill leakage under root scans. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(augment): guard symlinked skills/commands subdir in the skills walk The skills project walk symlink-checks the parent .augment dir but then used type_dir.is_dir() on the skills/commands subdir, which follows symlinks — so under a root MDM scan a user could point .augment/skills at an arbitrary dir and have the scanner traverse it. Add the matching `not type_dir.is_symlink()` guard (mirrors the parent-dir guard in the same method). All OSes via the inherited macOS walk. Adds a regression test (symlinked .augment/skills not traversed; real one still collected). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: thatcatfromspace Co-authored-by: Claude Opus 4.8 (1M context) --- .../ai_tools_discovery.py | 369 ++++++++++++++ .../augment_skills_helpers.py | 126 +++++ .../coding_tool_base.py | 83 ++++ .../coding_tool_factory.py | 140 ++++++ .../coding_discovery_tools/linux/__init__.py | 6 + .../linux/augment/__init__.py | 22 + .../linux/augment/augment.py | 35 ++ .../augment/augment_mcp_config_extractor.py | 41 ++ .../linux/augment/augment_rules_extractor.py | 53 ++ .../augment/augment_settings_extractor.py | 25 + .../linux/augment/augment_skills_extractor.py | 63 +++ .../macos/augment/__init__.py | 22 + .../macos/augment/augment.py | 321 ++++++++++++ .../augment/augment_mcp_config_extractor.py | 246 ++++++++++ .../macos/augment/augment_rules_extractor.py | 374 ++++++++++++++ .../augment/augment_settings_extractor.py | 180 +++++++ .../macos/augment/augment_skills_extractor.py | 193 ++++++++ .../windows/augment/__init__.py | 22 + .../windows/augment/augment.py | 64 +++ .../augment/augment_mcp_config_extractor.py | 49 ++ .../augment/augment_rules_extractor.py | 50 ++ .../augment/augment_settings_extractor.py | 26 + .../augment/augment_skills_extractor.py | 64 +++ tests/test_augment_discovery.py | 463 ++++++++++++++++++ tests/test_augment_overcollection.py | 422 ++++++++++++++++ tests/test_augment_rules_extraction.py | 245 +++++++++ tests/test_augment_settings_extraction.py | 228 +++++++++ tests/test_augment_skills_extraction.py | 218 +++++++++ tests/test_augment_skills_extraction_linux.py | 80 +++ tests/test_discovery_flow.py | 82 ++++ 30 files changed, 4312 insertions(+) create mode 100644 scripts/coding_discovery_tools/augment_skills_helpers.py create mode 100644 scripts/coding_discovery_tools/linux/augment/__init__.py create mode 100644 scripts/coding_discovery_tools/linux/augment/augment.py create mode 100644 scripts/coding_discovery_tools/linux/augment/augment_mcp_config_extractor.py create mode 100644 scripts/coding_discovery_tools/linux/augment/augment_rules_extractor.py create mode 100644 scripts/coding_discovery_tools/linux/augment/augment_settings_extractor.py create mode 100644 scripts/coding_discovery_tools/linux/augment/augment_skills_extractor.py create mode 100644 scripts/coding_discovery_tools/macos/augment/__init__.py create mode 100644 scripts/coding_discovery_tools/macos/augment/augment.py create mode 100644 scripts/coding_discovery_tools/macos/augment/augment_mcp_config_extractor.py create mode 100644 scripts/coding_discovery_tools/macos/augment/augment_rules_extractor.py create mode 100644 scripts/coding_discovery_tools/macos/augment/augment_settings_extractor.py create mode 100644 scripts/coding_discovery_tools/macos/augment/augment_skills_extractor.py create mode 100644 scripts/coding_discovery_tools/windows/augment/__init__.py create mode 100644 scripts/coding_discovery_tools/windows/augment/augment.py create mode 100644 scripts/coding_discovery_tools/windows/augment/augment_mcp_config_extractor.py create mode 100644 scripts/coding_discovery_tools/windows/augment/augment_rules_extractor.py create mode 100644 scripts/coding_discovery_tools/windows/augment/augment_settings_extractor.py create mode 100644 scripts/coding_discovery_tools/windows/augment/augment_skills_extractor.py create mode 100644 tests/test_augment_discovery.py create mode 100644 tests/test_augment_overcollection.py create mode 100644 tests/test_augment_rules_extraction.py create mode 100644 tests/test_augment_settings_extraction.py create mode 100644 tests/test_augment_skills_extraction.py create mode 100644 tests/test_augment_skills_extraction_linux.py diff --git a/scripts/coding_discovery_tools/ai_tools_discovery.py b/scripts/coding_discovery_tools/ai_tools_discovery.py index 1371fdb..a9a0c94 100644 --- a/scripts/coding_discovery_tools/ai_tools_discovery.py +++ b/scripts/coding_discovery_tools/ai_tools_discovery.py @@ -68,6 +68,10 @@ CopilotCliRulesExtractorFactory, CopilotCliSettingsExtractorFactory, CopilotCliSkillsExtractorFactory, + AugmentMCPConfigExtractorFactory, + AugmentRulesExtractorFactory, + AugmentSettingsExtractorFactory, + AugmentSkillsExtractorFactory, JunieMCPConfigExtractorFactory, JunieRulesExtractorFactory, CursorCliSettingsExtractorFactory, @@ -122,6 +126,10 @@ CopilotCliRulesExtractorFactory, CopilotCliSettingsExtractorFactory, CopilotCliSkillsExtractorFactory, + AugmentMCPConfigExtractorFactory, + AugmentRulesExtractorFactory, + AugmentSettingsExtractorFactory, + AugmentSkillsExtractorFactory, JunieMCPConfigExtractorFactory, JunieRulesExtractorFactory, CursorCliSettingsExtractorFactory, @@ -144,6 +152,12 @@ detail_logger = logging.getLogger(__name__ + ".detail") configure_logger() +# Distinct "unset" sentinel for the per-scan Augment memo caches. ``None`` is a +# LEGITIMATE cached value (e.g. no MCP configured), so it cannot double as the +# "not yet computed" marker — otherwise the expensive whole-disk walk re-runs on +# every accessor call whenever the real result is ``None``. +_AUGMENT_CACHE_UNSET = object() + def _normalise_path(p: str) -> str: """Normalise a path string for cross-platform comparison. @@ -182,6 +196,43 @@ def _copilot_cli_owned_by_user(tool_filtered: Dict, user_home) -> bool: return owns_install or has_data +def _augment_owned_by_user(tool_filtered: Dict, user_home) -> bool: + """Whether a filtered Augment surface should be emitted for ``user_home``. + + Augment surfaces share one per-user ``~/.augment`` config dir (carried as + ``_config_path``). Under a root all-users scan the per-user loop re-runs for + every user, so a non-owner with no per-user data would otherwise get a + phantom surface row. Ownership is "owns the ``_config_path`` dir OR has + user-owned data". + + Unlike the Copilot CLI gate, MANAGED-scope permissions do NOT count as + user-owned data: managed (org-wide ``/etc/augment``) policy is attached to + the canonical row and survives ``filter_tool_projects_by_user`` for EVERY + user, so treating its mere presence as data would manufacture a phantom row + for a non-owner. User-owned data is: per-user ``projects`` OR a permissions + block whose scope is a non-managed (user/local) scope. + """ + own_path = tool_filtered.get("_config_path") or tool_filtered.get("install_path", "") + own_norm = _normalise_path(own_path) + user_norm = _normalise_path(str(user_home)) + owns_install = bool(own_norm) and ( + own_norm == user_norm or own_norm.startswith(user_norm + "/") + ) + + if bool(tool_filtered.get("projects")): + return True + + # A permissions block survives filtering for this user iff it is managed + # (kept for everyone) or its settings_path is under this user's home (kept + # only for the owner). Managed alone must NOT count; a surviving non-managed + # block means this user owns user-scope permissions here. + perms = tool_filtered.get("permissions") + if perms is not None and perms.get("settings_source") != "managed": + return True + + return owns_install + + class AIToolsDetector: """ Detector for AI coding tools on macOS and Windows. @@ -273,6 +324,32 @@ def __init__(self, os_name: Optional[str] = None): # Chat row). None until set by the detection loop. self._canonical_vscode_copilot: Optional[str] = None + # Augment Code MCP + rules + settings + skills extractors (all OSes). + self._augment_mcp_extractor = AugmentMCPConfigExtractorFactory.create(self.system) + self._augment_rules_extractor = AugmentRulesExtractorFactory.create(self.system) + self._augment_settings_extractor = AugmentSettingsExtractorFactory.create(self.system) + self._augment_skills_extractor = AugmentSkillsExtractorFactory.create(self.system) + + # Augment ships three surfaces sharing one ~/.augment config. The shared + # config (MCP/rules/skills/permissions) is the same whichever surface + # asks for it, so memoize each whole-disk walk to run at most once per + # scan, and attach it to a single canonical surface (the canonical + # picker below) so it is not duplicated across surface rows. + # Use the UNSET sentinel (not None) so a legitimate cached ``None`` + # (e.g. no MCP configured) still short-circuits the accessor instead + # of re-running the expensive walk on every call (D4 memoization). + self._augment_rules_cache = _AUGMENT_CACHE_UNSET + self._augment_skills_cache = _AUGMENT_CACHE_UNSET + self._augment_mcp_cache = _AUGMENT_CACHE_UNSET + self._augment_settings_cache = _AUGMENT_CACHE_UNSET + # Per-user canonical Augment surface: maps each user's ``_config_path`` + # (their ~/.augment) -> the chosen surface name (lowercased) that + # carries the shared config for THAT user. Computed once per scan. + # Keyed per-config so a root multi-user scan picks a canonical surface + # for EACH user independently (user A's CLI doesn't steal canonical + # status from user B who only has VS Code). + self._canonical_augment_surface_by_config: Dict[str, str] = {} + self._junie_mcp_extractor = JunieMCPConfigExtractorFactory.create(self.system) self._junie_rules_extractor = JunieRulesExtractorFactory.create(self.system) @@ -1425,6 +1502,40 @@ def _copilot_skill_owner_home(skill: Dict) -> str: except Exception: return file_path + @staticmethod + def _augment_skill_project_root(skill: Dict) -> Optional[str]: + """Derive the project_root for a user-scope Augment skill/command. + + Augment reads user skills/commands from ``/.augment``, + ``/.claude`` and ``/.agents`` (each with ``skills/`` and + ``commands/``). The project_root is the CONFIG DIR the item lives in — the + path prefix up to and including that marker (e.g. ``/.augment``), + NOT the bare home. This (a) coalesces ``~/.augment`` skills with the same + row's ``~/.augment`` rules/MCP into ONE project (the backend keys an + AIToolProject per path, so a bare-home root would surface a spurious + project), while ``~/.claude`` / ``~/.agents`` skills group honestly under + their own dir; and (b) keeps each item owner-scoped (the home is in the + path) so the per-user project filter attributes it correctly under root + scans — preventing cross-user skill-content leakage. + + Operates on the raw path string (not ``pathlib``) to preserve the + original separator; handles the Windows ``\\`` variant. Returns None when + the file_path is missing/unparseable so the caller falls back to install_key. + """ + file_path = skill.get("file_path", "") + if not file_path: + return None + try: + for marker in (".augment", ".claude", ".agents"): + for sep in ("/", "\\"): + idx = file_path.find(f"{sep}{marker}{sep}") + if idx != -1: + # up to and including the marker dir (drop the trailing sep) + return file_path[: idx + 1 + len(marker)] + return None + except Exception: + return None + def _process_copilot_cli_tool(self, tool: Dict) -> Dict: """ Process the GitHub Copilot CLI: extract its MCP config + rules and return @@ -1610,6 +1721,242 @@ def _set_canonical_vscode_copilot(self, tools: List[Dict]) -> None: else: self._canonical_vscode_copilot = None + # -- Augment Code: memoized shared-config accessors ----------------------- + + def _get_augment_rules(self) -> List[Dict]: + """Return the shared Augment rules, memoized per scan ([] on failure).""" + if self._augment_rules_cache is not _AUGMENT_CACHE_UNSET: + return self._augment_rules_cache + if self._augment_rules_extractor: + try: + self._augment_rules_cache = self._augment_rules_extractor.extract_all_augment_rules() or [] + except Exception as e: + logger.warning(f" Augment rules extraction failed: {e}") + self._augment_rules_cache = [] + else: + self._augment_rules_cache = [] + return self._augment_rules_cache + + def _get_augment_skills(self) -> Dict: + """Return the shared Augment skills, memoized per scan ({} on failure).""" + if self._augment_skills_cache is not _AUGMENT_CACHE_UNSET: + return self._augment_skills_cache + if self._augment_skills_extractor: + try: + self._augment_skills_cache = self._augment_skills_extractor.extract_all_skills() or {} + except Exception as e: + logger.warning(f" Augment skills extraction failed: {e}") + self._augment_skills_cache = {} + else: + self._augment_skills_cache = {} + return self._augment_skills_cache + + def _get_augment_mcp(self) -> Optional[Dict]: + """Return the shared Augment MCP config, memoized per scan (None on failure). + + ``None`` is a legitimate result (no MCP configured), so the guard checks + the distinct UNSET sentinel — otherwise a cached ``None`` would re-trigger + the full MCP walk on every call, defeating the once-per-scan memoization. + """ + if self._augment_mcp_cache is not _AUGMENT_CACHE_UNSET: + return self._augment_mcp_cache + if self._augment_mcp_extractor: + try: + self._augment_mcp_cache = self._augment_mcp_extractor.extract_mcp_config() + except Exception as e: + logger.warning(f" Augment MCP extraction failed: {e}") + self._augment_mcp_cache = None + else: + self._augment_mcp_cache = None + return self._augment_mcp_cache + + def _get_augment_settings(self) -> List[Dict]: + """Return the shared Augment settings, memoized per scan ([] on failure).""" + if self._augment_settings_cache is not _AUGMENT_CACHE_UNSET: + return self._augment_settings_cache + if self._augment_settings_extractor: + try: + self._augment_settings_cache = self._augment_settings_extractor.extract_settings() or [] + except Exception as e: + logger.warning(f" Augment settings extraction failed: {e}") + self._augment_settings_cache = [] + else: + self._augment_settings_cache = [] + return self._augment_settings_cache + + @staticmethod + def _is_augment_surface(name_lower: str) -> bool: + """True for an Augment surface name (Auggie CLI or an ``Augment (...)`` row).""" + return name_lower == "auggie cli" or name_lower.startswith("augment (") + + @staticmethod + def _pick_canonical_augment_name(names_lower: List[str]) -> Optional[str]: + """Pick the canonical surface among ``names_lower`` (all sharing one config). + + Preference: Auggie CLI > Augment (VS Code) > first JetBrains row. None when + the group is empty. + """ + if "auggie cli" in names_lower: + return "auggie cli" + if "augment (vs code)" in names_lower: + return "augment (vs code)" + return next((n for n in names_lower if n.startswith("augment (")), None) + + def _set_canonical_augment_surface(self, tools: List[Dict]) -> None: + """Pick the canonical Augment surface PER USER (keyed by ``_config_path``). + + Augment surfaces share one ``~/.augment`` config dir PER USER. Under a root + multi-user scan, each user's surfaces carry that user's ``_config_path``, so + the canonical pick must be computed per-config — otherwise a single global + winner (e.g. user A's CLI) would leave a different user (B, VS Code only) + with a bare row that drops B's config. + + Groups the Augment surfaces by ``_config_path`` and stores, per group, the + best surface name (Auggie CLI > Augment (VS Code) > first JetBrains) in + ``_canonical_augment_surface_by_config``. Missing/empty ``_config_path`` + groups under ``""``. Computed once per scan from the full detected list. + """ + names_by_config: Dict[str, List[str]] = {} + for tool in tools: + name_lower = tool.get("name", "").lower() + if not self._is_augment_surface(name_lower): + continue + cfg = tool.get("_config_path") or "" + names_by_config.setdefault(cfg, []).append(name_lower) + + self._canonical_augment_surface_by_config = { + cfg: chosen + for cfg, names_lower in names_by_config.items() + if (chosen := self._pick_canonical_augment_name(names_lower)) is not None + } + + def _process_augment_tool(self, tool: Dict) -> Dict: + """ + Process an Augment Code surface row. + + Augment ships three surfaces sharing one ``~/.augment`` config PER USER. + Only the canonical surface for that user's ``_config_path`` + (``_canonical_augment_surface_by_config``) carries the shared + MCP/rules/skills/permissions; the non-canonical surfaces emit a bare + detection row (``projects=[]``, no permissions) so the surface still shows + in inventory without duplicating config. Keying canonical status per + ``_config_path`` means a root multi-user scan picks a winner for EACH user + independently (so a VS Code-only user still carries their config). + + Returns a tool dict with ``name``, ``version``, ``install_path``, + ``_config_path`` and ``projects``. + """ + tool_name = tool.get("name", "").lower() + cfg = tool.get("_config_path") or "" + + result = { + "name": tool.get("name"), + "version": tool.get("version"), + "install_path": tool.get("install_path"), + "_config_path": tool.get("_config_path"), + } + if "ide" in tool: + result["ide"] = tool["ide"] + + if tool_name != self._canonical_augment_surface_by_config.get(cfg): + logger.info(f" {tool.get('name')} is a non-canonical Augment surface; emitting bare row") + result["projects"] = [] + return result + + projects_dict: Dict[str, Dict] = {} + + logger.info(f" Extracting {tool.get('name')} MCP configs...") + mcp_config = self._get_augment_mcp() + if mcp_config and "projects" in mcp_config: + for project in mcp_config["projects"]: + project_path = project.get("path", "") + if project_path: + bucket = projects_dict.setdefault( + project_path, {"mcpServers": [], "rules": [], "skills": []} + ) + bucket["mcpServers"] = self._union_mcp_servers( + bucket.get("mcpServers", []), project.get("mcpServers", []) + ) + log_mcp_details(projects_dict, tool_name) + + logger.info(f" Extracting {tool.get('name')} rules...") + for rules_project in self._get_augment_rules(): + project_root = rules_project.get("project_root", "") + rules = rules_project.get("rules", []) + if project_root: + bucket = projects_dict.setdefault( + project_root, {"mcpServers": [], "rules": [], "skills": []} + ) + bucket["rules"] = self._deduplicate_project_items(rules) + + logger.info(f" Extracting {tool.get('name')} skills...") + skills_result = self._get_augment_skills() + user_skills = skills_result.get("user_skills", []) + project_skills = skills_result.get("project_skills", []) + + install_key = tool.get("_config_path") or tool.get("install_path") or str(Path.home()) + # User-scope skills key under the OWNING user's home (derived from each + # skill's file_path), NOT this row's single install_key — so under a + # root/all-users scan filter_tool_projects_by_user scopes each user's + # skills to their own home (no cross-user leak). Falls back to + # install_key when file_path is missing/unparseable (matches copilot). + for skill in user_skills: + skill_key = self._augment_skill_project_root(skill) or install_key + bucket = projects_dict.setdefault( + skill_key, {"mcpServers": [], "rules": [], "skills": []} + ) + bucket.setdefault("skills", []).append(skill) + + for skills_project in project_skills: + project_root = skills_project.get("project_root", "") + skills = skills_project.get("skills", []) + if not project_root: + continue + bucket = projects_dict.setdefault( + project_root, {"mcpServers": [], "rules": [], "skills": []} + ) + existing = bucket.setdefault("skills", []) + existing.extend(skills) + bucket["skills"] = self._deduplicate_project_items(existing) + + logger.info(f" Extracting {tool.get('name')} permissions...") + all_settings = self._get_augment_settings() + config_dir = tool.get("_config_path") or tool.get("install_path", "") + # Keep this row's USER-scope record (settings file directly under the + # canonical ~/.augment) AND the MANAGED-scope record (fixed machine path + # /etc/augment, attached to the canonical row — filter_tool_projects_by_user + # preserves managed). The transform then selects highest precedence + # (managed > user) for the effective policy. The extractor no longer emits + # project/local scopes (they can't be surfaced in the tool-level blob). + own = [ + s for s in all_settings + if s.get("scope") == "managed" + or ( + config_dir and s.get("settings_path") + and Path(str(s["settings_path"])).parent == Path(config_dir) + ) + ] + permissions_payload = transform_settings_to_backend_format(own) if own else None + + projects_list = [ + { + "path": path, + "mcpServers": data.get("mcpServers", []), + "rules": data.get("rules", []), + "skills": data.get("skills", []), + } + for path, data in projects_dict.items() + if not self._is_project_empty(data) + ] + + logger.info(f" ✓ Final project count: {len(projects_list)} project(s)") + logger.info("=" * 70) + + result["projects"] = projects_list + if permissions_payload: + result["permissions"] = permissions_payload + return result + def process_single_tool(self, tool: Dict) -> Dict: """ Process a single tool: extract rules and MCP configs, then return tool data with projects. @@ -1644,6 +1991,13 @@ def process_single_tool(self, tool: Dict) -> Dict: if tool_name == "github copilot cli": return self._process_copilot_cli_tool(tool) + # Augment Code surfaces (Auggie CLI / Augment (VS Code) / Augment ()). + # MUST come before the generic JetBrains ``_config_path`` fallback below — + # Augment JetBrains rows carry ``_config_path`` and would otherwise route + # to the JetBrains handler. + if tool_name == "auggie cli" or tool_name.startswith("augment ("): + return self._process_augment_tool(tool) + if "github copilot" in tool_name: logger.info(f" Extracting {tool_name} rules...") projects_dict = {} @@ -2170,6 +2524,7 @@ def generate_report(self) -> Dict: user_info = get_user_info() tools = self.detect_all_tools() self._set_canonical_vscode_copilot(tools) + self._set_canonical_augment_surface(tools) tools_with_projects = [] for tool in tools: @@ -2566,6 +2921,8 @@ def _on_term_signal(signum, _frame) -> None: # Pick the single VS Code Copilot row that should carry the shared skills. detector._set_canonical_vscode_copilot(tools) + # Pick the single Augment surface that should carry the shared config. + detector._set_canonical_augment_surface(tools) # Process each tool, then explore all users for that tool and send reports for tool in tools: @@ -2619,6 +2976,18 @@ def _on_term_signal(signum, _frame) -> None: ) continue + # Ownership gate (Augment surfaces): same ~/.augment-keyed + # logic so a non-owner under a root scan doesn't get a + # phantom surface row. + if (tool_name == "Auggie CLI" or tool_name.lower().startswith("augment (")) \ + and not _augment_owned_by_user(tool_filtered, user_home): + logger.info( + f" Skipping {tool_name} for {user_name}: config dir " + f"{tool_filtered.get('_config_path') or tool_filtered.get('install_path')!r} " + f"not owned by this user and no per-user data" + ) + continue + # Detect subscription plan for Claude Code if tool_name.lower() == "claude code": try: diff --git a/scripts/coding_discovery_tools/augment_skills_helpers.py b/scripts/coding_discovery_tools/augment_skills_helpers.py new file mode 100644 index 0000000..12a3115 --- /dev/null +++ b/scripts/coding_discovery_tools/augment_skills_helpers.py @@ -0,0 +1,126 @@ +""" +Shared helper functions for Augment Code skills/commands extraction. + +Delegates to the generic config-driven functions in ``claude_code_skills_helpers``, +passing Augment's directory names via the ``parent_dir_names`` / ``user_dir_names`` +parameters. For Augment Code an agent skill is a subdirectory containing a +``SKILL.md`` and a command is a flat ``.md`` file: + + - User/global: ~/.augment/skills//SKILL.md and ~/.augment/commands/*.md + - Project: /.augment/skills//SKILL.md + /.augment/commands/*.md + +Augment has no plugin system, so every skill/command is ``source="standalone"``. +""" + +import logging +from pathlib import Path +from typing import Callable, Dict, List, Optional + +from .claude_code_skills_helpers import ( + ItemTypeConfig, + is_skill_md_file, + is_command_md_file, + find_item_project_root, + extract_item_info, + extract_items_from_directory, + extract_user_level_items, +) + +logger = logging.getLogger(__name__) + +# ────────────────────────────────────────────────────────────────────────────── +# Constants +# ────────────────────────────────────────────────────────────────────────────── + +AUGMENT_DIR_NAME = ".augment" +CLAUDE_DIR_NAME = ".claude" +AGENTS_DIR_NAME = ".agents" +SKILLS_DIR_NAME = "skills" +COMMANDS_DIR_NAME = "commands" +SKILL_FILE_NAME = "SKILL.md" + +# Augment loads skills/commands from .augment, .claude AND .agents — in BOTH the +# workspace and the home dir (docs.augmentcode.com/cli/skills; .claude/commands is +# also honored for Claude compatibility). So the same .claude/.agents item is +# reported under Claude Code / Copilot CLI AND Augment; that is intentional — each +# tool reports what it actually loads, and the backend dedups per (tool, home_user). +AUGMENT_PARENT_DIR_NAMES = (AUGMENT_DIR_NAME, CLAUDE_DIR_NAME, AGENTS_DIR_NAME) +AUGMENT_USER_DIR_NAMES = (AUGMENT_DIR_NAME, CLAUDE_DIR_NAME, AGENTS_DIR_NAME) + +# ────────────────────────────────────────────────────────────────────────────── +# Config-driven item type definitions +# ────────────────────────────────────────────────────────────────────────────── + +AUGMENT_SKILL_CONFIG = ItemTypeConfig( + type_name="skill", + dir_name=SKILLS_DIR_NAME, + layout="nested", + file_filter=is_skill_md_file, + name_extractor=lambda f: f.parent.name, +) + +AUGMENT_COMMAND_CONFIG = ItemTypeConfig( + type_name="command", + dir_name=COMMANDS_DIR_NAME, + layout="flat", + file_filter=is_command_md_file, + name_extractor=lambda f: f.stem, +) + +AUGMENT_ITEM_CONFIGS = [AUGMENT_SKILL_CONFIG, AUGMENT_COMMAND_CONFIG] + +# ────────────────────────────────────────────────────────────────────────────── +# Augment-specific thin delegations to the generic functions +# ────────────────────────────────────────────────────────────────────────────── + + +def find_augment_item_project_root(item_file: Path, config: ItemTypeConfig) -> Path: + """Find the project root for an Augment skill/command file. Delegates to generic.""" + return find_item_project_root(item_file, config, parent_dir_names=AUGMENT_PARENT_DIR_NAMES) + + +def extract_augment_item_info( + item_file: Path, + extract_single_rule_file_func: Callable, + scope: str, + config: ItemTypeConfig, +) -> Optional[Dict]: + """Extract information from an Augment skill/command file. Delegates to generic.""" + return extract_item_info( + item_file, extract_single_rule_file_func, scope, config, + parent_dir_names=AUGMENT_PARENT_DIR_NAMES, + ) + + +def extract_augment_items_from_directory( + type_dir: Path, + projects_by_root: Dict[str, List[Dict]], + extract_single_rule_file_func: Callable, + add_skill_func: Callable, + config: ItemTypeConfig, +) -> None: + """Extract all skills/commands from an Augment dir. Delegates to generic.""" + extract_items_from_directory( + type_dir, projects_by_root, extract_single_rule_file_func, add_skill_func, config, + parent_dir_names=AUGMENT_PARENT_DIR_NAMES, + ) + + +def extract_augment_user_level_items( + user_home: Path, + user_skills: List[Dict], + extract_single_rule_file_func: Callable, + configs: List[ItemTypeConfig], +) -> None: + """Extract user-level Augment skills/commands from a user's home directory. + + Looks under each of ``AUGMENT_USER_DIR_NAMES`` (``~/.augment``, ``~/.claude``, + ``~/.agents``) since Augment loads home-scope skills/commands from all three. + Delegates to the shared engine. + """ + extract_user_level_items( + user_home, user_skills, extract_single_rule_file_func, configs, + user_dir_names=AUGMENT_USER_DIR_NAMES, + parent_dir_names=AUGMENT_PARENT_DIR_NAMES, + ) diff --git a/scripts/coding_discovery_tools/coding_tool_base.py b/scripts/coding_discovery_tools/coding_tool_base.py index b718828..449d9bd 100644 --- a/scripts/coding_discovery_tools/coding_tool_base.py +++ b/scripts/coding_discovery_tools/coding_tool_base.py @@ -359,6 +359,89 @@ def extract_settings(self) -> Optional[List[Dict]]: pass +class BaseAugmentRulesExtractor(ABC): + """Abstract base class for extracting Augment Code rules from all projects. + + For Augment Code (config under ``~/.augment/``), distinct from the IDE + Copilot/Claude surfaces. Mirrors ``BaseCopilotCliRulesExtractor`` — a single + product, no ``tool_name`` argument. + """ + + @abstractmethod + def extract_all_augment_rules(self) -> List[Dict]: + """ + Extract all Augment Code rules from all projects on the machine. + + Searches for: + - User (scope "user"): ``~/.augment/user-guidelines.md`` and + ``~/.augment/rules/**/*.{md,mdx}`` + - Project (scope "project"): repo-root ``.augment-guidelines``, + ``/.augment/rules/**/*.{md,mdx}``, and ``AGENTS.md`` / ``CLAUDE.md`` + discovered hierarchically (depth-bounded). + + Returns: + List of project dicts, each containing: + - project_root: Path to the project root directory + - rules: List of rule file dicts with metadata (file_path, file_name, + content, size, last_modified, truncated, scope) + """ + pass + + +class BaseAugmentSettingsExtractor(ABC): + """Abstract base class for extracting Augment Code settings/permissions. + + For Augment Code (config under ``~/.augment/``). Mirrors + ``BaseClaudeSettingsExtractor`` — returns a list of per-scope settings dicts + routed through ``transform_settings_to_backend_format``. + """ + + @abstractmethod + def extract_settings(self) -> Optional[List[Dict]]: + """ + Extract Augment Code settings (permissions + full settings JSON). + + Searches for: + - User: ``~/.augment/settings.json`` + - Managed: ``/etc/augment/settings.json`` + - Project: ``/.augment/settings.json`` and + ``/.augment/settings.local.json`` (local scope) + + ``toolPermissions`` is parsed into ``permissions.{allow,deny,ask}`` and the + full settings JSON (including ``hooks``) is preserved in ``raw_settings``. + + Returns: + List of per-scope settings dicts, or ``None``/empty if nothing found. + """ + pass + + +class BaseAugmentSkillsExtractor(ABC): + """Abstract base class for extracting Augment Code skills. + + For Augment Code. Augment has no plugin system, so skills carry + ``source="standalone"``. + """ + + @abstractmethod + def extract_all_skills(self) -> Dict: + """ + Extract all Augment Code skills from all projects on the machine. + + Searches: + - User-level: ~/.augment/skills//SKILL.md, ~/.augment/commands/*.md + - Project-level: **/.augment/commands/*.md, **/.augment/skills//SKILL.md + + Returns: + Dict with: + - user_skills: List of user-level skill dicts (scope "user") + - project_skills: List of project dicts, each containing: + - project_root: Path to the project root + - skills: List of skill dicts with metadata + """ + pass + + class BaseJunieRulesExtractor(ABC): """Abstract base class for extracting Junie rules from all projects.""" diff --git a/scripts/coding_discovery_tools/coding_tool_factory.py b/scripts/coding_discovery_tools/coding_tool_factory.py index e984225..3010832 100644 --- a/scripts/coding_discovery_tools/coding_tool_factory.py +++ b/scripts/coding_discovery_tools/coding_tool_factory.py @@ -26,6 +26,9 @@ BaseCopilotCliRulesExtractor, BaseCopilotCliSettingsExtractor, BaseCopilotCliSkillsExtractor, + BaseAugmentRulesExtractor, + BaseAugmentSettingsExtractor, + BaseAugmentSkillsExtractor, BaseMCPConfigExtractor, BaseClaudeSettingsExtractor, BaseCursorSettingsExtractor, @@ -122,6 +125,13 @@ from .macos.copilot_cli.copilot_cli_settings_extractor import MacOSCopilotCliSettingsExtractor from .macos.copilot_cli.copilot_cli_skills_extractor import MacOSCopilotCliSkillsExtractor +# macOS - Augment Code (Auggie CLI + VS Code + JetBrains surfaces; ~/.augment) +from .macos.augment.augment import MacOSAugmentDetector +from .macos.augment.augment_mcp_config_extractor import MacOSAugmentMCPConfigExtractor +from .macos.augment.augment_rules_extractor import MacOSAugmentRulesExtractor +from .macos.augment.augment_settings_extractor import MacOSAugmentSettingsExtractor +from .macos.augment.augment_skills_extractor import MacOSAugmentSkillsExtractor + # Windows - Copilot from .windows.github_copilot.detect_copilot import WindowsGitHubCopilotDetector from .windows.github_copilot.mcp_config_extractor import WindowsGitHubCopilotMCPConfigExtractor @@ -134,6 +144,13 @@ from .windows.copilot_cli.copilot_cli_settings_extractor import WindowsCopilotCliSettingsExtractor from .windows.copilot_cli.copilot_cli_skills_extractor import WindowsCopilotCliSkillsExtractor +# Windows - Augment Code +from .windows.augment.augment import WindowsAugmentDetector +from .windows.augment.augment_mcp_config_extractor import WindowsAugmentMCPConfigExtractor +from .windows.augment.augment_rules_extractor import WindowsAugmentRulesExtractor +from .windows.augment.augment_settings_extractor import WindowsAugmentSettingsExtractor +from .windows.augment.augment_skills_extractor import WindowsAugmentSkillsExtractor + # Windows - Replit from .windows.replit.replit import WindowsReplitDetector # Windows - Codex @@ -219,6 +236,11 @@ LinuxCopilotCliRulesExtractor, LinuxCopilotCliSettingsExtractor, LinuxCopilotCliSkillsExtractor, + LinuxAugmentDetector, + LinuxAugmentMCPConfigExtractor, + LinuxAugmentRulesExtractor, + LinuxAugmentSettingsExtractor, + LinuxAugmentSkillsExtractor, LinuxCodexDetector, LinuxCodexRulesExtractor, LinuxCodexMCPConfigExtractor, @@ -675,6 +697,22 @@ def create_copilot_cli_detector(os_name: Optional[str] = None) -> Optional[BaseT else: return None + @staticmethod + def create_augment_detector(os_name: Optional[str] = None) -> Optional[BaseToolDetector]: + """ + Create appropriate Augment Code detector for the OS. + """ + if os_name is None: + os_name = platform.system() + if os_name == "Darwin": + return MacOSAugmentDetector() + elif os_name == "Windows": + return WindowsAugmentDetector() + elif os_name == "Linux": + return LinuxAugmentDetector() + else: + return None + @staticmethod def create_jetbrains_detector(os_name: Optional[str] = None) -> Optional[BaseToolDetector]: """ @@ -793,6 +831,11 @@ def create_all_tool_detectors(os_name: Optional[str] = None) -> list: if copilot_cli_detector is not None: detectors.append(copilot_cli_detector) + # Add Augment Code detector (macOS + Windows + Linux) + augment_detector = ToolDetectorFactory.create_augment_detector(os_name) + if augment_detector is not None: + detectors.append(augment_detector) + # Add JetBrains detector for macOS jetbrains_detector = ToolDetectorFactory.create_jetbrains_detector(os_name) if jetbrains_detector is not None: @@ -1558,6 +1601,103 @@ def create(os_name: Optional[str] = None) -> Optional[BaseCopilotCliSkillsExtrac return None +class AugmentMCPConfigExtractorFactory: + """Factory for creating OS-specific Augment Code MCP config extractors.""" + + @staticmethod + def create(os_name: Optional[str] = None) -> Optional[BaseMCPConfigExtractor]: + """ + Create an Augment Code MCP config extractor for the OS. + + The parser + User-scope read are OS-agnostic; the Windows/Linux extractors + are thin subclasses overriding only the workspace walk. + """ + if os_name is None: + os_name = platform.system() + + if os_name == "Darwin": + return MacOSAugmentMCPConfigExtractor() + elif os_name == "Windows": + return WindowsAugmentMCPConfigExtractor() + elif os_name == "Linux": + return LinuxAugmentMCPConfigExtractor() + else: + return None + + +class AugmentRulesExtractorFactory: + """Factory for creating OS-specific Augment Code rules extractors.""" + + @staticmethod + def create(os_name: Optional[str] = None) -> Optional[BaseAugmentRulesExtractor]: + """ + Create an Augment Code rules extractor for the OS. + + The source set + depth-bounded walk are shared in the macOS class; the + Windows/Linux subclasses override only the OS-specific seams. + """ + if os_name is None: + os_name = platform.system() + + if os_name == "Darwin": + return MacOSAugmentRulesExtractor() + elif os_name == "Windows": + return WindowsAugmentRulesExtractor() + elif os_name == "Linux": + return LinuxAugmentRulesExtractor() + else: + return None + + +class AugmentSettingsExtractorFactory: + """Factory for creating OS-specific Augment Code settings extractors.""" + + @staticmethod + def create(os_name: Optional[str] = None) -> Optional[BaseAugmentSettingsExtractor]: + """ + Create an Augment Code settings/permissions extractor for the OS. + + Parses ``toolPermissions`` + preserves the full settings JSON (incl. + hooks) in raw_settings; Windows/Linux are thin subclasses overriding the + all-users scan, managed path, and filesystem seams. + """ + if os_name is None: + os_name = platform.system() + + if os_name == "Darwin": + return MacOSAugmentSettingsExtractor() + elif os_name == "Windows": + return WindowsAugmentSettingsExtractor() + elif os_name == "Linux": + return LinuxAugmentSettingsExtractor() + else: + return None + + +class AugmentSkillsExtractorFactory: + """Factory for creating OS-specific Augment Code skills extractors.""" + + @staticmethod + def create(os_name: Optional[str] = None) -> Optional[BaseAugmentSkillsExtractor]: + """ + Create an Augment Code skills extractor for the OS. + + Reuses the shared skills engine; Windows/Linux are thin (single-threaded) + subclasses overriding only the OS seams. + """ + if os_name is None: + os_name = platform.system() + + if os_name == "Darwin": + return MacOSAugmentSkillsExtractor() + elif os_name == "Windows": + return WindowsAugmentSkillsExtractor() + elif os_name == "Linux": + return LinuxAugmentSkillsExtractor() + else: + return None + + class GitHubCopilotRulesExtractorFactory: """Factory for creating OS-specific GitHub Copilot rules extractors.""" diff --git a/scripts/coding_discovery_tools/linux/__init__.py b/scripts/coding_discovery_tools/linux/__init__.py index 6599150..0dd0fe6 100644 --- a/scripts/coding_discovery_tools/linux/__init__.py +++ b/scripts/coding_discovery_tools/linux/__init__.py @@ -11,6 +11,7 @@ from .gemini_cli import LinuxGeminiCliDetector, LinuxGeminiCliRulesExtractor, LinuxGeminiCliMCPConfigExtractor from .cursor_cli import LinuxCursorCliDetector, LinuxCursorCliRulesExtractor, LinuxCursorCliMCPConfigExtractor, LinuxCursorCliSettingsExtractor from .copilot_cli import LinuxCopilotCliDetector, LinuxCopilotCliMCPConfigExtractor, LinuxCopilotCliRulesExtractor, LinuxCopilotCliSettingsExtractor, LinuxCopilotCliSkillsExtractor +from .augment import LinuxAugmentDetector, LinuxAugmentMCPConfigExtractor, LinuxAugmentRulesExtractor, LinuxAugmentSettingsExtractor, LinuxAugmentSkillsExtractor from .codex import LinuxCodexDetector, LinuxCodexRulesExtractor, LinuxCodexMCPConfigExtractor from .opencode import LinuxOpenCodeDetector, LinuxOpenCodeRulesExtractor, LinuxOpenCodeMCPConfigExtractor from .openclaw import LinuxOpenClawDetector @@ -54,6 +55,11 @@ "LinuxCopilotCliRulesExtractor", "LinuxCopilotCliSettingsExtractor", "LinuxCopilotCliSkillsExtractor", + "LinuxAugmentDetector", + "LinuxAugmentMCPConfigExtractor", + "LinuxAugmentRulesExtractor", + "LinuxAugmentSettingsExtractor", + "LinuxAugmentSkillsExtractor", "LinuxCodexDetector", "LinuxCodexRulesExtractor", "LinuxCodexMCPConfigExtractor", diff --git a/scripts/coding_discovery_tools/linux/augment/__init__.py b/scripts/coding_discovery_tools/linux/augment/__init__.py new file mode 100644 index 0000000..49545a9 --- /dev/null +++ b/scripts/coding_discovery_tools/linux/augment/__init__.py @@ -0,0 +1,22 @@ +""" +Augment Code detection and extraction for Linux. + +Augment Code (``~/.augment/``) ships three surfaces — the Auggie CLI, the VS Code +extension, and the JetBrains plugin — that share one config dir. The detector and +extractors reuse the OS-agnostic macOS logic, overriding only the all-users scan +and the Linux filesystem primitives. +""" + +from .augment import LinuxAugmentDetector +from .augment_mcp_config_extractor import LinuxAugmentMCPConfigExtractor +from .augment_rules_extractor import LinuxAugmentRulesExtractor +from .augment_settings_extractor import LinuxAugmentSettingsExtractor +from .augment_skills_extractor import LinuxAugmentSkillsExtractor + +__all__ = [ + "LinuxAugmentDetector", + "LinuxAugmentMCPConfigExtractor", + "LinuxAugmentRulesExtractor", + "LinuxAugmentSettingsExtractor", + "LinuxAugmentSkillsExtractor", +] diff --git a/scripts/coding_discovery_tools/linux/augment/augment.py b/scripts/coding_discovery_tools/linux/augment/augment.py new file mode 100644 index 0000000..6afd47b --- /dev/null +++ b/scripts/coding_discovery_tools/linux/augment/augment.py @@ -0,0 +1,35 @@ +""" +Augment Code detection for Linux. + +Augment Code keeps its config under ``~/.augment/`` (identical to macOS). This +subclass inherits the full macOS detection surface (per-surface CLI/VS Code/ +JetBrains rows) and overrides only the all-users scan (``get_linux_user_homes()``) +and the Linux JetBrains detector. The ``auggie`` binary resolve and the version +probe are inherited unchanged (the per-user ``.local``/``.bun``/nvm prefixes apply +on Linux too). +""" + +from pathlib import Path +from typing import List + +from ...linux.jetbrains.jetbrains import LinuxJetBrainsDetector +from ...linux_extraction_helpers import get_linux_user_homes +from ...macos.augment.augment import MacOSAugmentDetector + + +class LinuxAugmentDetector(MacOSAugmentDetector): + """Detector for Augment Code surfaces on Linux systems.""" + + def _iter_scan_homes(self) -> List[Path]: + """User homes to scan: this user (scoped), else every Linux user home. + + ``get_linux_user_homes`` returns all human users when running as root + (including ``/root``), else just the current user's home. + """ + if self.user_home is not None: + return [self.user_home] + return [Path(home) for home in get_linux_user_homes()] + + def _make_jetbrains_detector(self): + """OS seam: the Linux JetBrains detector.""" + return LinuxJetBrainsDetector() diff --git a/scripts/coding_discovery_tools/linux/augment/augment_mcp_config_extractor.py b/scripts/coding_discovery_tools/linux/augment/augment_mcp_config_extractor.py new file mode 100644 index 0000000..16c3810 --- /dev/null +++ b/scripts/coding_discovery_tools/linux/augment/augment_mcp_config_extractor.py @@ -0,0 +1,41 @@ +""" +MCP config extraction for Augment Code on Linux systems. + +The parser and User-scope read are OS-agnostic and inherited from the macOS +extractor. Only the workspace walk differs on Linux (``/`` root + Linux system +dirs), so this subclass overrides the ``_workspace_search_roots`` / +``_should_skip_workspace_path`` seams. +""" + +import logging +from pathlib import Path +from typing import List, Tuple + +from ...linux_extraction_helpers import ( + get_top_level_directories, + should_skip_path, + should_skip_system_path, +) +from ...macos.augment.augment_mcp_config_extractor import ( + MacOSAugmentMCPConfigExtractor, +) + +logger = logging.getLogger(__name__) + + +class LinuxAugmentMCPConfigExtractor(MacOSAugmentMCPConfigExtractor): + """Augment MCP extractor on Linux; overrides only the workspace walk.""" + + def _workspace_search_roots(self) -> List[Tuple[Path, Path]]: + """``(root_path, start_dir)`` pairs for the project walk (Linux).""" + root_path = Path("/") + try: + return [(root_path, top_dir) for top_dir in get_top_level_directories(root_path)] + except (PermissionError, OSError) as exc: + logger.debug(f"Falling back to home for workspace scan: {exc}") + home = Path.home() + return [(home, home)] + + def _should_skip_workspace_path(self, item: Path) -> bool: + """Skip predicate for the Linux workspace walk (skip + Linux system dirs).""" + return should_skip_path(item) or should_skip_system_path(item) diff --git a/scripts/coding_discovery_tools/linux/augment/augment_rules_extractor.py b/scripts/coding_discovery_tools/linux/augment/augment_rules_extractor.py new file mode 100644 index 0000000..a1bc678 --- /dev/null +++ b/scripts/coding_discovery_tools/linux/augment/augment_rules_extractor.py @@ -0,0 +1,53 @@ +""" +Augment Code rules/guidelines extraction for Linux systems. + +The source set and the depth-bounded walk are OS-agnostic and inherited from +``MacOSAugmentRulesExtractor`` (DRY). This subclass overrides only the +OS-specific seams via ``linux_extraction_helpers`` (note the Linux +``should_skip_system_path`` must NOT skip ``/home``). +""" + +import logging +from pathlib import Path +from typing import List + +from ...constants import traverses_other_tool_config_dir +from ...linux_extraction_helpers import ( + get_linux_user_homes, + get_top_level_directories, + is_running_as_root, + should_skip_path, + should_skip_system_path, +) +from ...macos.augment.augment_rules_extractor import MacOSAugmentRulesExtractor + +logger = logging.getLogger(__name__) + + +class LinuxAugmentRulesExtractor(MacOSAugmentRulesExtractor): + """Augment Code rules extractor on Linux (overrides OS seams only).""" + + def _is_privileged(self) -> bool: + return is_running_as_root() + + def _scan_all_user_homes(self, extract_for_user) -> None: + # Guard each user so one unreadable home (PermissionError/OSError) can't + # abort the whole multi-user scan — parity with the Linux skills extractor. + for user_home in get_linux_user_homes(): + try: + extract_for_user(Path(user_home)) + except (PermissionError, OSError) as e: + logger.debug(f"Skipping {user_home}: {e}") + + def _filesystem_root(self) -> Path: + return Path("/") + + def _iter_top_level_dirs(self, root_path: Path) -> List[Path]: + return list(get_top_level_directories(root_path)) + + def _should_skip(self, item: Path) -> bool: + return ( + should_skip_path(item) + or should_skip_system_path(item) + or traverses_other_tool_config_dir(item) + ) diff --git a/scripts/coding_discovery_tools/linux/augment/augment_settings_extractor.py b/scripts/coding_discovery_tools/linux/augment/augment_settings_extractor.py new file mode 100644 index 0000000..a92a584 --- /dev/null +++ b/scripts/coding_discovery_tools/linux/augment/augment_settings_extractor.py @@ -0,0 +1,25 @@ +""" +Augment Code settings/permissions extraction for Linux. + +The settings/permissions parsing is OS-agnostic and inherited from +``MacOSAugmentSettingsExtractor``. The managed path +(``/etc/augment/settings.json``) is shared with macOS and inherited; only the +all-users scan is Linux-specific. +""" + +from pathlib import Path + +from ...linux_extraction_helpers import ( + get_linux_user_homes, +) +from ...macos.augment.augment_settings_extractor import ( + MacOSAugmentSettingsExtractor, +) + + +class LinuxAugmentSettingsExtractor(MacOSAugmentSettingsExtractor): + """Augment Code settings extractor on Linux (overrides OS seams only).""" + + def _user_settings_scan(self, extract_for_user) -> None: + for user_home in get_linux_user_homes(): + extract_for_user(Path(user_home)) diff --git a/scripts/coding_discovery_tools/linux/augment/augment_skills_extractor.py b/scripts/coding_discovery_tools/linux/augment/augment_skills_extractor.py new file mode 100644 index 0000000..43dd033 --- /dev/null +++ b/scripts/coding_discovery_tools/linux/augment/augment_skills_extractor.py @@ -0,0 +1,63 @@ +""" +Augment Code skills/commands extraction for Linux systems. + +The per-tool config and project-grouping logic are inherited from +``MacOSAugmentSkillsExtractor`` (DRY); only the OS primitives are overridden via +seams. Note the Linux ``should_skip_system_path`` must NOT skip ``/home`` (unlike +macOS), or project skills under user homes are dropped, and the user-level-dir +check must handle both ``/home/`` and ``/root``. +""" + +import logging +from pathlib import Path +from typing import List + +from ...constants import traverses_other_tool_config_dir +from ...linux_extraction_helpers import ( + extract_single_rule_file, + get_linux_user_homes, + get_top_level_directories, + should_skip_path, + should_skip_system_path, +) +from ...claude_code_skills_helpers import is_user_level_claude_subdir +from ...macos.augment.augment_skills_extractor import MacOSAugmentSkillsExtractor + +logger = logging.getLogger(__name__) + + +class LinuxAugmentSkillsExtractor(MacOSAugmentSkillsExtractor): + """Augment Code skills extractor on Linux (overrides OS seams only).""" + + def _extract_single_rule_file(self, *args, **kwargs): + return extract_single_rule_file(*args, **kwargs) + + def _should_skip_walk_item(self, item: Path) -> bool: + return ( + should_skip_path(item) + or should_skip_system_path(item) + or traverses_other_tool_config_dir(item) + ) + + def _scan_all_user_homes(self, extract_for_user) -> None: + for user_home in get_linux_user_homes(): + try: + extract_for_user(Path(user_home)) + except (PermissionError, OSError) as e: + logger.debug(f"Skipping {user_home}: {e}") + + def _filesystem_root(self) -> Path: + return Path("/") + + def _iter_top_level_dirs(self, root_path: Path) -> List[Path]: + return list(get_top_level_directories(root_path)) + + def _is_user_level_skill_dir(self, type_dir: Path) -> bool: + """Linux has two home shapes: ``/home/`` and ``/root``. Pin the + users-root to ``/home`` and add an explicit ``/root`` check.""" + if is_user_level_claude_subdir(type_dir, users_root_path="/home"): + return True + try: + return type_dir.parent.parent == Path("/root") + except (OSError, ValueError): + return False diff --git a/scripts/coding_discovery_tools/macos/augment/__init__.py b/scripts/coding_discovery_tools/macos/augment/__init__.py new file mode 100644 index 0000000..d9490d7 --- /dev/null +++ b/scripts/coding_discovery_tools/macos/augment/__init__.py @@ -0,0 +1,22 @@ +""" +Augment Code detection and extraction for macOS. + +Augment Code (``~/.augment/``) ships three surfaces — the Auggie CLI, the VS Code +extension, and the JetBrains plugin — that share one config dir. The detector +emits a row per surface; the shared config (MCP / rules / skills / permissions) is +attached to a single canonical surface downstream. +""" + +from .augment import MacOSAugmentDetector +from .augment_mcp_config_extractor import MacOSAugmentMCPConfigExtractor +from .augment_rules_extractor import MacOSAugmentRulesExtractor +from .augment_settings_extractor import MacOSAugmentSettingsExtractor +from .augment_skills_extractor import MacOSAugmentSkillsExtractor + +__all__ = [ + 'MacOSAugmentDetector', + 'MacOSAugmentMCPConfigExtractor', + 'MacOSAugmentRulesExtractor', + 'MacOSAugmentSettingsExtractor', + 'MacOSAugmentSkillsExtractor', +] diff --git a/scripts/coding_discovery_tools/macos/augment/augment.py b/scripts/coding_discovery_tools/macos/augment/augment.py new file mode 100644 index 0000000..a680572 --- /dev/null +++ b/scripts/coding_discovery_tools/macos/augment/augment.py @@ -0,0 +1,321 @@ +""" +Augment Code detection for macOS. + +Augment Code ships three surfaces that share one ``~/.augment/`` config dir: + + - Auggie CLI: the standalone ``@augmentcode/auggie`` agentic terminal tool. + - Augment (VS Code): the ``augment.vscode-augment`` marketplace extension. + - Augment (): the JetBrains plugin (any plugin name containing "augment"). + +Each surface is emitted as its own detection row (mirroring +``MacOSCopilotDetector``), and the discovery loop flattens the returned list. The +shared ``~/.augment`` config (MCP / rules / skills / permissions) is attached to a +single canonical surface downstream so it is not duplicated across rows. +""" + +import json +import logging +import os +import re +from pathlib import Path +from typing import Dict, List, Optional + +from ...coding_tool_base import BaseToolDetector +from ...constants import VERSION_TIMEOUT +from ...macos.jetbrains.jetbrains import MacOSJetBrainsDetector +from ...macos_extraction_helpers import is_running_as_root +from ...utils import run_command + +logger = logging.getLogger(__name__) + +_AUGMENT_DIR_NAME = ".augment" +# VS Code marketplace extension ids (stable + nightly). +_VSCODE_EXTENSION_IDS = ("augment.vscode-augment", "augment.vscode-augment-nightly") +# Substring matched (case-insensitive) against a JetBrains plugin name. +_JETBRAINS_PLUGIN_MATCH = "augment" + +_VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:[.\-+][0-9A-Za-z.\-]+)?") + + +def _resolve_augment_dir(user_home: Path) -> Path: + """Return the Augment config directory for ``user_home`` (``~/.augment``). + + Augment has no documented config-dir override env var, so this is always + ``/.augment`` (verified: no AUGMENT_HOME/AUGGIE_HOME exists). + """ + return user_home / _AUGMENT_DIR_NAME + + +def _resolve_auggie_binary(user_home: Path) -> Optional[Path]: + """Return the per-user ``auggie`` CLI binary for ``user_home``, if found. + + The CLI installs to a per-user location that root's PATH does not include + during an MDM all-users scan, so we resolve the binary explicitly from the + documented/observed install locations, in order: + + - ``~/.local/bin/auggie`` (npm/standalone user install) + - ``~/.bun/bin/auggie`` (Bun global install) + - ``~/.nvm/versions/node/*/bin/auggie`` (nvm-managed Node; newest first) + + Best-effort only: any error is swallowed and None is returned. Never raises. + """ + def _node_version_key(version_dir: Path): + nums = re.findall(r"\d+", version_dir.name) + return tuple(int(n) for n in nums) + + try: + for candidate in ( + user_home / ".local" / "bin" / "auggie", + user_home / ".bun" / "bin" / "auggie", + ): + try: + if candidate.exists() and os.access(str(candidate), os.X_OK): + return candidate + except OSError: + continue + nvm_node_dir = user_home / ".nvm" / "versions" / "node" + try: + for version_dir in sorted(nvm_node_dir.glob("*"), key=_node_version_key, reverse=True): + try: + candidate = version_dir / "bin" / "auggie" + if candidate.exists() and os.access(str(candidate), os.X_OK): + return candidate + except OSError: + continue + except OSError: + pass + except (PermissionError, OSError) as exc: + logger.debug(f"Error resolving Auggie CLI binary for {user_home}: {exc}") + return None + + +def _parse_cli_version(raw: Optional[str]) -> Optional[str]: + """Extract a clean semver from raw ``auggie --version`` output. + + E.g. ``"0.30.0 (commit 690bba03)"`` -> ``"0.30.0"``. Falls back to the first + non-empty line (capped) when no semver is present; None/garbage -> None. + """ + if not raw: + return None + match = _VERSION_RE.search(raw) + if match: + return match.group(0) + first_line = next((line.strip() for line in raw.splitlines() if line.strip()), "") + return first_line[:50] or None + + +def _load_extension_json(path: Path) -> List[Dict]: + """Parse a VS Code ``extensions.json`` file; ``[]`` on any failure.""" + try: + if not path.is_file(): + return [] + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except (json.JSONDecodeError, OSError, ValueError): + return [] + + +class MacOSAugmentDetector(BaseToolDetector): + """ + Detects Augment Code across the Auggie CLI, VS Code, and JetBrains on macOS. + + When ``self.user_home`` is set (the live per-user discovery path), detection is + scoped to that single user; otherwise, when running as root, all users under + ``/Users`` are scanned, and for a regular user only their own home is checked. + Each surface yields its own row whose ``_config_path`` is that user's resolved + ``~/.augment`` dir. + """ + + def __init__(self) -> None: + self.user_home: Optional[Path] = None + + @property + def tool_name(self) -> str: + """Return the name of the tool being detected.""" + return "Augment Code" + + def detect(self) -> Optional[List[Dict]]: + """ + Detect all Augment Code surfaces on macOS. + + Returns the concatenated CLI + VS Code + JetBrains rows, or None when none + are found. The discovery loop flattens the returned list. + """ + results: List[Dict] = [] + results.extend(self._detect_auggie_cli_all_users()) + results.extend(self._detect_vscode_all_users()) + results.extend(self._detect_jetbrains_all_users()) + return results or None + + def detect_all_tools(self, user_home: Optional[str] = None) -> List[Dict]: + """Entry point mirroring other multi-result detectors.""" + if user_home is not None: + self.user_home = Path(user_home) + return self.detect() or [] + + def get_version(self, binary: Optional[str] = None) -> Optional[str]: + """Extract the Auggie CLI version via ``auggie --version`` (best-effort).""" + if binary is not None: + try: + return _parse_cli_version( + run_command([str(binary), "--version"], VERSION_TIMEOUT) + ) + except Exception as exc: + logger.debug(f"Could not extract Auggie CLI version from resolved binary: {exc}") + return None + try: + return _parse_cli_version(run_command(["auggie", "--version"], VERSION_TIMEOUT)) + except Exception as exc: + logger.debug(f"Could not extract Auggie CLI version: {exc}") + return None + + # -- per-surface, all-users helpers -------------------------------------- + + def _iter_scan_homes(self) -> List[Path]: + """User homes to scan: this user (scoped), else /Users (root), else home.""" + if self.user_home is not None: + return [self.user_home] + if is_running_as_root(): + homes: List[Path] = [] + users_dir = Path("/Users") + try: + if users_dir.exists(): + for user_dir in users_dir.iterdir(): + if user_dir.is_dir() and not user_dir.name.startswith("."): + homes.append(user_dir) + except (PermissionError, OSError) as exc: + logger.debug(f"Error scanning /Users for Augment: {exc}") + return homes + return [Path.home()] + + def _detect_auggie_cli_all_users(self) -> List[Dict]: + results: List[Dict] = [] + for user_home in self._iter_scan_homes(): + try: + row = self._detect_auggie_cli_for_user(user_home) + if row: + results.append(row) + except (PermissionError, OSError) as exc: + logger.debug(f"Skipping Auggie CLI for {user_home}: {exc}") + return results + + def _detect_auggie_cli_for_user(self, user_home: Path) -> Optional[Dict]: + """Gate the Auggie CLI row on the resolved ``auggie`` binary.""" + binary = self._resolve_binary(user_home) + if not binary: + return None + return { + "name": "Auggie CLI", + "version": self.get_version(binary) or "unknown", + "publisher": "Augment Computer", + "install_path": binary, + "_config_path": str(_resolve_augment_dir(user_home)), + } + + def _resolve_binary(self, user_home: Path) -> Optional[str]: + """Resolve the ``auggie`` CLI binary for ``user_home`` (the CLI gate).""" + per_user = _resolve_auggie_binary(user_home) + return str(per_user) if per_user is not None else None + + def _detect_vscode_all_users(self) -> List[Dict]: + results: List[Dict] = [] + for user_home in self._iter_scan_homes(): + try: + results.extend(self._detect_vscode_for_user(user_home)) + except (PermissionError, OSError) as exc: + logger.debug(f"Skipping Augment VS Code for {user_home}: {exc}") + return results + + def _detect_vscode_for_user(self, user_home: Path) -> List[Dict]: + """Emit AT MOST ONE "Augment (VS Code)" row per user. + + Both the stable (``augment.vscode-augment``) and nightly + (``augment.vscode-augment-nightly``) extensions can be installed at once. + Emitting both produces two identically-named rows sharing one + ``_config_path`` -> duplicate canonical candidates. Prefer the stable + extension; fall back to nightly only when stable is absent, using the + chosen extension's version. + """ + vscode_ext_path = user_home / ".vscode" / "extensions" / "extensions.json" + versions_by_id: Dict[str, str] = {} + for ext in _load_extension_json(vscode_ext_path): + ext_id = ext.get("identifier", {}).get("id", "").lower() + if ext_id in _VSCODE_EXTENSION_IDS: + # _VSCODE_EXTENSION_IDS is ordered (stable, nightly); index 0 is + # the stable id we prefer. + versions_by_id[ext_id] = ext.get("version", "unknown") + + chosen_version = next( + (versions_by_id[ext_id] for ext_id in _VSCODE_EXTENSION_IDS + if ext_id in versions_by_id), + None, + ) + if chosen_version is None: + return [] + return [{ + "name": "Augment (VS Code)", + "version": chosen_version, + "publisher": "Augment Computer", + "install_path": str(vscode_ext_path.parent), + "_config_path": str(_resolve_augment_dir(user_home)), + }] + + def _detect_jetbrains_all_users(self) -> List[Dict]: + """Detect Augment's JetBrains plugin, attributing each IDE to its OWNER. + + ``MacOSJetBrainsDetector`` already scans the running user's home (and ALL + users under root), so we invoke it ONCE and derive each IDE's owning user + from the IDE's own config path. Stamping the outer scan home instead would, + under a root all-users scan, attribute one user's IDE to another user's + ``~/.augment`` (wrong permissions/config) and re-run the scan N times. + """ + candidate_homes = self._iter_scan_homes() + try: + ides = self._make_jetbrains_detector().detect() or [] + except (PermissionError, OSError) as exc: + logger.debug(f"Skipping Augment JetBrains detection: {exc}") + return [] + + results: List[Dict] = [] + for ide in ides: + plugins = ide.get("plugins", []) + if not any(_JETBRAINS_PLUGIN_MATCH in str(name).lower() for name in plugins): + continue + ide_path = ide.get("config_path") or ide.get("install_path") + owner_home = self._augment_owner_home_for_path(ide_path, candidate_homes) + results.append({ + "name": f"Augment ({ide['name']})", + "version": ide.get("version", "unknown"), + "publisher": "Augment Computer", + "ide": ide["name"], + "install_path": ide_path, + "_config_path": str(_resolve_augment_dir(owner_home)), + }) + return results + + def _augment_owner_home_for_path(self, ide_path, candidate_homes: List[Path]) -> Path: + """Owning user's home for a JetBrains IDE config path. + + Matches the IDE's own path against the scanned user homes (longest prefix + wins) so each row's ``_config_path`` points at the IDE owner's + ``~/.augment`` — correct under a root all-users scan where the JetBrains + detector returns every user's IDEs. Falls back to the scoped/current home + when no scanned home is a prefix. Separator-normalised for Windows. + """ + if ide_path: + ide_norm = str(ide_path).replace("\\", "/").rstrip("/") + best = None + best_len = -1 + for home in candidate_homes: + home_norm = str(home).replace("\\", "/").rstrip("/") + if home_norm and (ide_norm == home_norm or ide_norm.startswith(home_norm + "/")): + if len(home_norm) > best_len: + best, best_len = home, len(home_norm) + if best is not None: + return best + return self.user_home or Path.home() + + def _make_jetbrains_detector(self): + """OS seam: the JetBrains detector for this platform.""" + return MacOSJetBrainsDetector() diff --git a/scripts/coding_discovery_tools/macos/augment/augment_mcp_config_extractor.py b/scripts/coding_discovery_tools/macos/augment/augment_mcp_config_extractor.py new file mode 100644 index 0000000..3d804f6 --- /dev/null +++ b/scripts/coding_discovery_tools/macos/augment/augment_mcp_config_extractor.py @@ -0,0 +1,246 @@ +""" +MCP config extraction for Augment Code on macOS. + +Augment loads MCP servers from the User config (``~/.augment/settings.json``, +optional ``~/.augment/mcp*.json``) and Workspace files +(``/.augment/settings.json``). MCP servers may live at the top-level +``mcpServers`` key OR nested under ``augment.advanced.mcpServers`` (and a flat +unwrapped form is tolerated). Mirrors ``MacOSCopilotCliMCPConfigExtractor``. +""" + +import json +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from ...coding_tool_base import BaseMCPConfigExtractor +from ...macos_extraction_helpers import ( + get_top_level_directories, + should_skip_path, + should_skip_system_path, +) +from ...mcp_extraction_helpers import ( + extract_ide_global_configs_with_root_support, + transform_mcp_servers_to_array, + _strip_jsonc_comments, + _strip_trailing_commas, +) +from .augment import _resolve_augment_dir + +logger = logging.getLogger(__name__) + +_TOOL_NAME = "Augment Code" +_AUGMENT_DIR_NAME = ".augment" +_SETTINGS_FILENAME = "settings.json" +# Glob for any additional user-scope MCP files (~/.augment/mcp*.json). +_MCP_FILE_GLOB = "mcp*.json" + + +def _extract_servers_obj(config_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Resolve the server mapping from a parsed Augment config. + + Order of precedence: + 1. top-level ``mcpServers`` (canonical wrapped form) + 2. nested ``augment.advanced.mcpServers`` + 3. flat top-level object of ``{name: {config}}`` — a value counts as a server + iff it is a dict carrying a ``command`` or ``url``. + """ + wrapped = config_data.get("mcpServers") + if isinstance(wrapped, dict): + return wrapped + + advanced = config_data.get("augment") + if isinstance(advanced, dict): + advanced = advanced.get("advanced") + if isinstance(advanced, dict): + nested = advanced.get("mcpServers") + if isinstance(nested, dict): + return nested + + return { + name: value + for name, value in config_data.items() + if isinstance(value, dict) + and ("command" in value or "url" in value) + } + + +class MacOSAugmentMCPConfigExtractor(BaseMCPConfigExtractor): + """Extractor for Augment Code MCP config on macOS systems.""" + + def extract_mcp_config( + self, plugin_lookup: Optional[Dict] = None + ) -> Optional[Dict]: + """ + Extract Augment MCP config: User (``~/.augment/settings.json`` + + ``~/.augment/mcp*.json``, root-aware) plus Workspace + (``/.augment/settings.json``). Returns a ``projects`` dict, or + None if empty. + """ + # Track each user-home ``~/.augment`` collected as USER scope so the + # workspace walk below doesn't re-collect the same settings.json as a + # PROJECT-scope config — that would duplicate the identical MCP servers + # under two project paths (~/.augment as user + the home dir as project). + self._scanned_user_augment_dirs = set() + + projects = extract_ide_global_configs_with_root_support( + self._extract_user_configs_for_user, + tool_name=_TOOL_NAME, + ) + + projects.extend(self._extract_workspace_configs()) + + if not projects: + return None + + return {"projects": projects} + + def _extract_user_configs_for_user(self, user_home: Path) -> List[Dict]: + """Read the User-scope MCP config(s) for a single user's ``~/.augment``.""" + augment_dir = _resolve_augment_dir(user_home) + if not hasattr(self, "_scanned_user_augment_dirs"): + self._scanned_user_augment_dirs = set() + try: + self._scanned_user_augment_dirs.add(augment_dir.resolve()) + except (OSError, RuntimeError): + self._scanned_user_augment_dirs.add(augment_dir) + configs: List[Dict] = [] + + settings_config = self._read_mcp_config( + augment_dir / _SETTINGS_FILENAME, str(augment_dir), "user" + ) + if settings_config: + configs.append(settings_config) + + try: + for mcp_file in sorted(augment_dir.glob(_MCP_FILE_GLOB)): + config = self._read_mcp_config(mcp_file, str(augment_dir), "user") + if config: + configs.append(config) + except (PermissionError, OSError) as exc: + logger.debug(f"Error globbing Augment MCP files in {augment_dir}: {exc}") + + return configs + + # -- Workspace scope: project-root .augment/settings.json ---------------- + + def _extract_workspace_configs(self) -> List[Dict]: + """Walk ``_workspace_search_roots`` for project ``.augment/settings.json``.""" + projects: List[Dict] = [] + for root_path, start_dir in self._workspace_search_roots(): + try: + start_depth = len(start_dir.relative_to(root_path).parts) + except ValueError: + start_depth = 0 + try: + self._walk_for_workspace_configs( + root_path, start_dir, projects, current_depth=start_depth + ) + except (PermissionError, OSError) as exc: + logger.debug(f"Skipping workspace scan of {start_dir}: {exc}") + except Exception as exc: + logger.debug(f"Error scanning workspace dir {start_dir}: {exc}") + return projects + + def _walk_for_workspace_configs( + self, + root_path: Path, + current_dir: Path, + projects: List[Dict], + current_depth: int = 0, + ) -> None: + """Recursively look for ``/.augment/settings.json`` (bounded).""" + from ...constants import MAX_SEARCH_DEPTH + + if current_depth > MAX_SEARCH_DEPTH: + return + try: + for item in current_dir.iterdir(): + try: + if self._should_skip_workspace_path(item): + continue + if not item.is_dir() or item.is_symlink(): + continue + if item.name == _AUGMENT_DIR_NAME: + # Skip a user-home ~/.augment already collected as USER + # scope, so its MCP servers aren't duplicated as project. + try: + resolved = item.resolve() + except (OSError, RuntimeError): + resolved = item + if resolved in getattr(self, "_scanned_user_augment_dirs", set()): + continue + config = self._read_mcp_config( + item / _SETTINGS_FILENAME, str(item.parent), "project" + ) + if config: + projects.append(config) + continue + self._walk_for_workspace_configs( + root_path, item, projects, current_depth + 1 + ) + except (PermissionError, OSError): + continue + except Exception as exc: + logger.debug(f"Error processing {item}: {exc}") + continue + except (PermissionError, OSError): + pass + except Exception as exc: + logger.debug(f"Error walking {current_dir}: {exc}") + + def _workspace_search_roots(self) -> List[Tuple[Path, Path]]: + """``(root_path, start_dir)`` pairs for the project walk (macOS).""" + root_path = Path("/") + try: + return [(root_path, top_dir) for top_dir in get_top_level_directories(root_path)] + except (PermissionError, OSError) as exc: + logger.debug(f"Falling back to home for workspace scan: {exc}") + home = Path.home() + return [(home, home)] + + def _should_skip_workspace_path(self, item: Path) -> bool: + """Skip predicate for the macOS workspace walk (system + skip dirs).""" + return should_skip_path(item) or should_skip_system_path(item) + + def _read_mcp_config( + self, config_path: Path, tool_path: str, scope: str + ) -> Optional[Dict]: + """ + Read and parse an Augment config file for MCP servers. + + Strips JSONC comments + trailing commas, resolves the server mapping from + the top-level or nested or flat form, and transforms to the array shape. + All IO is wrapped — never crashes. + + Returns a dict with ``path``, ``mcpServers`` and ``scope`` keys, or None. + """ + try: + if not config_path.is_file(): + return None + + content = config_path.read_text(encoding="utf-8", errors="replace") + content = _strip_trailing_commas(_strip_jsonc_comments(content)) + config_data = json.loads(content) + + if not isinstance(config_data, dict): + return None + + mcp_servers_obj = _extract_servers_obj(config_data) + mcp_servers_array = transform_mcp_servers_to_array(mcp_servers_obj) + + if mcp_servers_array: + return { + "path": tool_path, + "mcpServers": mcp_servers_array, + "scope": scope, + } + except json.JSONDecodeError as exc: + logger.warning(f"Invalid JSON in {_TOOL_NAME} config {config_path}: {exc}") + except PermissionError as exc: + logger.debug(f"Permission denied reading {_TOOL_NAME} config {config_path}: {exc}") + except Exception as exc: + logger.warning(f"Error reading {_TOOL_NAME} config {config_path}: {exc}") + + return None diff --git a/scripts/coding_discovery_tools/macos/augment/augment_rules_extractor.py b/scripts/coding_discovery_tools/macos/augment/augment_rules_extractor.py new file mode 100644 index 0000000..b50ea50 --- /dev/null +++ b/scripts/coding_discovery_tools/macos/augment/augment_rules_extractor.py @@ -0,0 +1,374 @@ +""" +Augment Code rules/guidelines extraction for macOS. + +Augment Code (config under ``~/.augment/``) discovers guidelines from several +sources (paths verified against the architect's revised D3): + + - User (scope "user"): + ``~/.augment/user-guidelines.md`` + ``~/.augment/rules/**/*.{md,mdx}`` + - Project (scope "project"): + repo-root ``.augment-guidelines`` (single file) + ``/.augment/rules/**/*.{md,mdx}`` (recursive, bounded) + ``AGENTS.md`` and ``CLAUDE.md`` discovered hierarchically/recursively + (depth-bounded), mirroring how Augment walks subdir -> parents to root. + +Both ``.md`` and ``.mdx`` rule files are supported. User rules are grouped under +``~/.augment`` as their ``project_root`` so they coalesce with the user's MCP +servers + skills. Rule dicts are built ONLY via ``extract_single_rule_file`` with +an explicit ``scope`` — no frontmatter is parsed into the dict (the backend drops +any rule carrying a key outside its allowlist; frontmatter stays in ``content``). +""" + +import logging +from pathlib import Path +from typing import Dict, List + +from ...coding_tool_base import BaseAugmentRulesExtractor +from ...constants import MAX_SEARCH_DEPTH, traverses_other_tool_config_dir +from ...macos_extraction_helpers import ( + add_rule_to_project, + build_project_list, + extract_single_rule_file, + get_top_level_directories, + is_running_as_root, + scan_user_directories, + should_skip_path, + should_skip_system_path, +) +from .augment import _resolve_augment_dir + +logger = logging.getLogger(__name__) + +AUGMENT_DIR_NAME = ".augment" +RULES_DIR_NAME = "rules" +USER_GUIDELINES_FILENAME = "user-guidelines.md" +PROJECT_GUIDELINES_FILENAME = ".augment-guidelines" +# Project-root agent files Augment discovers hierarchically (subdir -> root). +HIERARCHICAL_RULE_FILES = ("AGENTS.md", "CLAUDE.md") +_RULE_SUFFIXES = (".md", ".mdx") + + +def _is_augment_rule_file(name: str) -> bool: + """True for ``.md`` / ``.mdx`` files (the Augment rules suffixes).""" + lower = name.lower() + return lower.endswith(_RULE_SUFFIXES) + + +def _make_fixed_root_finder(project_root: Path): + """Return a ``find_project_root_func`` that always yields ``project_root``.""" + def _finder(_rule_file: Path) -> Path: + return project_root + return _finder + + +def _find_augment_dir_root(rule_file: Path) -> Path: + """Project root for a file under a project's ``.augment/`` tree -> parent of + ``.augment``.""" + for ancestor in rule_file.parents: + if ancestor.name == AUGMENT_DIR_NAME: + return ancestor.parent + return rule_file.parent + + +def _find_self_dir_root(rule_file: Path) -> Path: + """Project root for a repo-level rule file -> the directory containing it.""" + return rule_file.parent + + +class MacOSAugmentRulesExtractor(BaseAugmentRulesExtractor): + """Extractor for Augment Code rules on macOS systems.""" + + def extract_all_augment_rules(self) -> List[Dict]: + """ + Extract all Augment Code rules from all projects on macOS. + + Returns: + List of project dicts, each containing: + - project_root: Path to the project root directory + - rules: List of rule file dicts (without project_root field) + """ + projects_by_root: Dict[str, List[Dict]] = {} + + self._extract_user_rules(projects_by_root) + # Compute the user-home ``~/.augment`` set ONCE (via the all-users seam) + # so the project walk skips them instead of re-collecting user rules as + # scope "project". + user_augment_dirs = self._user_augment_dirs() + self._extract_project_level_rules( + self._filesystem_root(), projects_by_root, user_augment_dirs + ) + + return build_project_list(projects_by_root) + + # -- User (user-scope) --------------------------------------------------- + + def _extract_user_rules(self, projects_by_root: Dict[str, List[Dict]]) -> None: + """Extract ``~/.augment/user-guidelines.md`` + ``~/.augment/rules/**``. + + Each user's ``~/.augment`` becomes the ``project_root`` so user rules + coalesce with that user's MCP servers + skills. + """ + def extract_for_user(user_home: Path) -> None: + try: + config_dir = _resolve_augment_dir(user_home) + root_finder = _make_fixed_root_finder(config_dir) + + self._add_rule_file( + config_dir / USER_GUIDELINES_FILENAME, + root_finder, + "user", + projects_by_root, + ) + self._add_rules_tree( + config_dir / RULES_DIR_NAME, + root_finder, + "user", + projects_by_root, + ) + except Exception as e: + logger.debug(f"Error extracting user Augment rules for {user_home}: {e}") + + self._scan_all_user_homes(extract_for_user) + + # -- Project (project-scope) --------------------------------------------- + + def _extract_project_level_rules( + self, + root_path: Path, + projects_by_root: Dict[str, List[Dict]], + user_augment_dirs: set, + ) -> None: + """Walk for project-level rules from the filesystem root.""" + if root_path == self._filesystem_root(): + try: + for top_dir in self._iter_top_level_dirs(root_path): + try: + self._walk_for_project_rules( + root_path, top_dir, projects_by_root, user_augment_dirs, current_depth=1 + ) + except (PermissionError, OSError) as e: + logger.debug(f"Skipping {top_dir}: {e}") + continue + except (PermissionError, OSError) as e: + logger.warning(f"Error accessing root directory: {e}") + else: + self._walk_for_project_rules( + root_path, root_path, projects_by_root, user_augment_dirs, current_depth=0 + ) + + def _walk_for_project_rules( + self, + root_path: Path, + current_dir: Path, + projects_by_root: Dict[str, List[Dict]], + user_augment_dirs: set, + current_depth: int = 0, + ) -> None: + """Recursively walk collecting ``.augment-guidelines``, ``.augment/rules/**``, + and hierarchical ``AGENTS.md`` / ``CLAUDE.md``. + + Symlinked directories are skipped (loop/perf risk on customer machines). + User-home ``~/.augment`` dirs (in ``user_augment_dirs``) are skipped — they + are already collected as user scope; descending here would re-emit the same + ``~/.augment/rules/**`` files as scope "project" (different project_root, so + ``_deduplicate_project_items`` — which dedups within one project — misses it). + """ + if current_depth > MAX_SEARCH_DEPTH: + return + + # Files in THIS directory: .augment-guidelines + AGENTS.md/CLAUDE.md + # (the latter discovered at every level, not just the repo root). + self._extract_dir_level_files(current_dir, projects_by_root) + + try: + for item in current_dir.iterdir(): + try: + if self._should_skip(item): + continue + + try: + depth = len(item.relative_to(root_path).parts) + if depth > MAX_SEARCH_DEPTH: + continue + except ValueError: + continue + + # Skip non-dirs and symlinked dirs BEFORE the .augment + # handling / recursion (mirrors the mcp + settings walk + # ordering) so a symlinked .augment can't be followed. + if not item.is_dir() or item.is_symlink(): + continue + + if item.name == AUGMENT_DIR_NAME: + # .augment/rules/** lives here; skip the user-home + # ~/.augment (collected as user scope) to avoid + # re-emitting user rules as scope "project"; otherwise + # handle this project's tree (don't recurse in). + if item.resolve() in user_augment_dirs: + continue + self._add_rules_tree( + item / RULES_DIR_NAME, + _find_augment_dir_root, + "project", + projects_by_root, + ) + continue + + self._walk_for_project_rules( + root_path, item, projects_by_root, user_augment_dirs, current_depth + 1 + ) + + except (PermissionError, OSError): + continue + except Exception as e: + logger.debug(f"Error processing {item}: {e}") + continue + except (PermissionError, OSError): + pass + except Exception as e: + logger.debug(f"Error walking {current_dir}: {e}") + + def _extract_dir_level_files( + self, project_dir: Path, projects_by_root: Dict[str, List[Dict]] + ) -> None: + """Collect ``.augment-guidelines`` + hierarchical ``AGENTS.md`` / ``CLAUDE.md`` + in ``project_dir`` (the directory itself, grouped under itself as root).""" + self._add_rule_file( + project_dir / PROJECT_GUIDELINES_FILENAME, + _find_self_dir_root, + "project", + projects_by_root, + ) + for file_name in HIERARCHICAL_RULE_FILES: + self._add_rule_file( + project_dir / file_name, + _find_self_dir_root, + "project", + projects_by_root, + ) + + # -- Shared building blocks ---------------------------------------------- + + def _add_rules_tree( + self, + rules_dir: Path, + find_project_root_func, + scope: str, + projects_by_root: Dict[str, List[Dict]], + ) -> None: + """Add every ``.md``/``.mdx`` under ``rules_dir`` (recursive, bounded).""" + try: + if not rules_dir.is_dir() or rules_dir.is_symlink(): + return + self._walk_rules_dir( + rules_dir, find_project_root_func, scope, projects_by_root, current_depth=0 + ) + except (PermissionError, OSError) as e: + logger.debug(f"Error reading rules dir {rules_dir}: {e}") + except Exception as e: + logger.debug(f"Error processing rules dir {rules_dir}: {e}") + + def _walk_rules_dir( + self, + current_dir: Path, + find_project_root_func, + scope: str, + projects_by_root: Dict[str, List[Dict]], + current_depth: int = 0, + ) -> None: + """Recurse a bounded ``rules/`` tree collecting ``.md``/``.mdx`` files.""" + if current_depth > MAX_SEARCH_DEPTH: + return + try: + for item in current_dir.iterdir(): + try: + if item.is_dir(): + if item.is_symlink(): + continue + self._walk_rules_dir( + item, find_project_root_func, scope, projects_by_root, current_depth + 1 + ) + elif item.is_file() and _is_augment_rule_file(item.name): + self._add_rule_file(item, find_project_root_func, scope, projects_by_root) + except (PermissionError, OSError): + continue + except Exception as e: + logger.debug(f"Error processing {item}: {e}") + continue + except (PermissionError, OSError): + pass + except Exception as e: + logger.debug(f"Error walking {current_dir}: {e}") + + def _add_rule_file( + self, + rule_file: Path, + find_project_root_func, + scope: str, + projects_by_root: Dict[str, List[Dict]], + ) -> None: + """Read one rule file (explicit scope) and add it under its project root.""" + try: + if not rule_file.is_file(): + return + rule_info = extract_single_rule_file(rule_file, find_project_root_func, scope=scope) + if rule_info: + project_root = rule_info.get("project_root") + if project_root: + add_rule_to_project(rule_info, project_root, projects_by_root) + except (PermissionError, OSError) as e: + logger.debug(f"Permission/OS error reading rule file {rule_file}: {e}") + except Exception as e: + logger.debug(f"Error extracting rule file {rule_file}: {e}") + + def _user_augment_dirs(self) -> set: + """Resolved set of user-home ``~/.augment`` dirs to skip in the project walk. + + Built via the all-users ``_scan_all_user_homes`` seam so it works per-OS + (the Linux/Windows subclasses override only that seam). Mirrors the + settings extractor's identically-named helper. + """ + dirs = set() + + def collect(user_home: Path) -> None: + try: + dirs.add(_resolve_augment_dir(user_home).resolve()) + except (PermissionError, OSError): + pass + + self._scan_all_user_homes(collect) + return dirs + + # -- OS-specific seams (overridden by the Windows/Linux subclasses) ------- + + def _is_privileged(self) -> bool: + """True when scanning all users (root on macOS).""" + return is_running_as_root() + + def _scan_all_user_homes(self, extract_for_user) -> None: + """Invoke ``extract_for_user(home)`` for every user home (all users when root).""" + if is_running_as_root(): + scan_user_directories(extract_for_user) + else: + extract_for_user(Path.home()) + + def _filesystem_root(self) -> Path: + """Root the project walk starts from (POSIX ``/`` on macOS).""" + return Path("/") + + def _iter_top_level_dirs(self, root_path: Path) -> List[Path]: + """Top-level dirs under the filesystem root, system dirs excluded.""" + return list(get_top_level_directories(root_path)) + + def _should_skip(self, item: Path) -> bool: + """Skip project/system dirs AND other-tool config dirs (``~/.``). + + ``.augment`` is NOT in OTHER_TOOL_CONFIG_DIRS, so the walk still descends + into it to collect ``.augment/rules/**``. + """ + return ( + should_skip_path(item) + or should_skip_system_path(item) + or traverses_other_tool_config_dir(item) + ) diff --git a/scripts/coding_discovery_tools/macos/augment/augment_settings_extractor.py b/scripts/coding_discovery_tools/macos/augment/augment_settings_extractor.py new file mode 100644 index 0000000..ba1d6ac --- /dev/null +++ b/scripts/coding_discovery_tools/macos/augment/augment_settings_extractor.py @@ -0,0 +1,180 @@ +""" +Augment Code settings/permissions extraction for macOS. + +Augment persists settings in ``settings.json`` files. This extractor reads: + + - User: ``~/.augment/settings.json`` (root-aware all-users scan) + - Managed: ``/etc/augment/settings.json`` + +Project/local-scope settings (``/.augment/settings.json`` / +``settings.local.json``) are intentionally NOT collected: they cannot be +surfaced in the tool-level ``permissions`` blob the backend supports (the +consumer keeps only the user-row + managed scopes), so an expensive +whole-filesystem walk to extract records that are then dropped is avoided. + +``toolPermissions`` is parsed into ``permissions.{allow,deny,ask}`` (an array of +``{toolName, shellInputRegex?, eventType, permission.type}``; a present +``shellInputRegex`` is appended to the tool name as ``toolName(regex)``). The FULL +parsed settings JSON — including ``hooks`` — is preserved in ``raw_settings`` so +the backend risk classifier sees those signals (``transform_settings_to_backend_format`` +does NOT lift hooks, so they MUST ride inside ``raw_settings``). +""" + +import json +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +from ...coding_tool_base import BaseAugmentSettingsExtractor +from ...macos_extraction_helpers import ( + is_running_as_root, + scan_user_directories, +) +from ...mcp_extraction_helpers import _strip_jsonc_comments, _strip_trailing_commas +from .augment import _resolve_augment_dir + +logger = logging.getLogger(__name__) + +TOOL_NAME = "Augment Code" +SETTINGS_FILENAME = "settings.json" +TOOL_PERMISSIONS_KEY = "toolPermissions" +# Cap an over-large settings file so a runaway file can't blow up the payload. +_MAX_SETTINGS_BYTES = 1_000_000 + +# permission.type -> permissions bucket. +_PERMISSION_TYPE_TO_BUCKET = { + "allow": "allow", + "deny": "deny", + "ask-user": "ask", +} + + +def _parse_jsonc(path: Path) -> Optional[Dict]: + """Leniently parse a JSON/JSONC settings file. None on any failure. + + Over-large files are truncated-and-skipped (warned), invalid JSON is warned + and skipped. Never raises — this runs on customer machines. + """ + try: + if not path.is_file(): + return None + size = path.stat().st_size + if size > _MAX_SETTINGS_BYTES: + logger.warning(f"Skipping oversize Augment settings file {path} ({size} bytes)") + return None + raw = path.read_text(encoding="utf-8", errors="replace") + cleaned = _strip_trailing_commas(_strip_jsonc_comments(raw)) + data = json.loads(cleaned) + return data if isinstance(data, dict) else None + except (PermissionError, OSError) as e: + logger.debug(f"Permission/OS error reading {path}: {e}") + return None + except json.JSONDecodeError as e: + logger.warning(f"Invalid JSON in Augment settings {path}: {e}") + return None + except Exception as e: + logger.debug(f"Could not parse {path}: {e}") + return None + + +def _parse_tool_permissions(settings_data: Dict[str, Any]) -> Dict[str, List[str]]: + """Map ``toolPermissions`` into ``{allow, deny, ask}``. + + Each entry is ``{toolName, shellInputRegex?, eventType, permission.type}``; a + present ``shellInputRegex`` is appended to the tool name as ``toolName(regex)``. + + ``additionalDirectories`` is always emitted EMPTY (kept for permissions-dict + shape parity with the other extractors): Augment has no trusted-folders / + additional-directories concept, so nothing populates it. + """ + buckets: Dict[str, List[str]] = {"allow": [], "deny": [], "ask": [], "additionalDirectories": []} + entries = settings_data.get(TOOL_PERMISSIONS_KEY) + if not isinstance(entries, list): + return buckets + + for entry in entries: + if not isinstance(entry, dict): + continue + permission = entry.get("permission") + ptype = permission.get("type") if isinstance(permission, dict) else None + bucket = _PERMISSION_TYPE_TO_BUCKET.get(ptype) + if bucket is None: + continue + tool = entry.get("toolName") + if not isinstance(tool, str) or not tool: + continue + regex = entry.get("shellInputRegex") + label = f"{tool}({regex})" if isinstance(regex, str) and regex else tool + buckets[bucket].append(label) + + return buckets + + +def _build_record(scope: str, settings_path: Path, settings_data: Dict[str, Any]) -> Dict: + """Build one per-scope settings record (hooks preserved in raw_settings).""" + buckets = _parse_tool_permissions(settings_data) + return { + "tool_name": TOOL_NAME, + "scope": scope, + "settings_path": str(settings_path), + "raw_settings": settings_data, + "permissions": { + "defaultMode": None, + "allow": buckets["allow"], + "deny": buckets["deny"], + "ask": buckets["ask"], + "additionalDirectories": buckets["additionalDirectories"], + }, + "mcp_servers": list(settings_data["mcpServers"].keys()) + if isinstance(settings_data.get("mcpServers"), dict) else [], + "mcp_policies": {"allowedMcpServers": [], "deniedMcpServers": []}, + "sandbox": {"enabled": None}, + } + + +class MacOSAugmentSettingsExtractor(BaseAugmentSettingsExtractor): + """Extractor for Augment Code settings on macOS systems.""" + + def extract_settings(self) -> Optional[List[Dict]]: + records: List[Dict] = [] + + self._extract_user_settings(records) + self._extract_managed_settings(records) + + return records + + def _extract_user_settings(self, records: List[Dict]) -> None: + """Extract ``~/.augment/settings.json`` (all users when root).""" + def extract_for_user(user_home: Path) -> None: + try: + settings_path = _resolve_augment_dir(user_home) / SETTINGS_FILENAME + settings_data = _parse_jsonc(settings_path) + if settings_data is not None: + records.append(_build_record("user", settings_path, settings_data)) + except Exception as e: + logger.debug(f"Error extracting user Augment settings for {user_home}: {e}") + + self._user_settings_scan(extract_for_user) + + def _extract_managed_settings(self, records: List[Dict]) -> None: + """Extract the managed (org-level) ``/etc/augment/settings.json``.""" + try: + managed_path = self._managed_settings_path() + settings_data = _parse_jsonc(managed_path) + if settings_data is not None: + records.append(_build_record("managed", managed_path, settings_data)) + except Exception as e: + logger.debug(f"Error extracting managed Augment settings: {e}") + + # -- OS-specific seams (overridden by the Windows/Linux subclasses) ------- + + def _user_settings_scan(self, extract_for_user) -> None: + """Invoke ``extract_for_user(home)`` for every user home (all users when root).""" + if is_running_as_root(): + scan_user_directories(extract_for_user) + else: + extract_for_user(Path.home()) + + def _managed_settings_path(self) -> Path: + """Managed (org-level) settings file path.""" + return Path("/etc/augment/settings.json") diff --git a/scripts/coding_discovery_tools/macos/augment/augment_skills_extractor.py b/scripts/coding_discovery_tools/macos/augment/augment_skills_extractor.py new file mode 100644 index 0000000..df9482f --- /dev/null +++ b/scripts/coding_discovery_tools/macos/augment/augment_skills_extractor.py @@ -0,0 +1,193 @@ +""" +Augment Code skills/commands extraction for macOS systems. + +Extracts agent skills (each a subdirectory containing a ``SKILL.md``) and slash +commands (flat ``.md`` files), grouping project items by project root. + +User/global: ~/.augment/skills//SKILL.md and ~/.augment/commands/*.md +Project: **/.augment/skills//SKILL.md and **/.augment/commands/*.md + +Reuses the shared config-driven engine in ``claude_code_skills_helpers`` via +``augment_skills_helpers``. Augment has no plugin system, so every item is +``source="standalone"``. +""" + +import logging +from pathlib import Path +from typing import Dict, List + +from ...coding_tool_base import BaseAugmentSkillsExtractor +from ...constants import MAX_SEARCH_DEPTH, traverses_other_tool_config_dir +from ...macos_extraction_helpers import ( + extract_single_rule_file, + get_top_level_directories, + is_running_as_root, + scan_user_directories, + should_process_directory, + should_skip_path, + should_skip_system_path, +) +from ...augment_skills_helpers import ( + AUGMENT_PARENT_DIR_NAMES, + AUGMENT_ITEM_CONFIGS, + extract_augment_items_from_directory, + extract_augment_user_level_items, +) +from ...claude_code_skills_helpers import ( + build_skills_project_list, + add_skill_to_project, + is_user_level_claude_subdir, +) + +logger = logging.getLogger(__name__) + + +class MacOSAugmentSkillsExtractor(BaseAugmentSkillsExtractor): + """Extractor for Augment Code skills/commands on macOS systems.""" + + def extract_all_skills(self) -> Dict: + """ + Extract all Augment Code skills/commands from all projects on macOS. + + Returns: + Dict with: + - user_skills: List of user-level skill/command dicts (scope "user") + - project_skills: List of {project_root, skills[]} project dicts + """ + user_skills: List[Dict] = [] + projects_by_root: Dict[str, List[Dict]] = {} + + self._extract_user_level_skills(user_skills) + self._extract_project_level_skills(self._filesystem_root(), projects_by_root) + + return { + "user_skills": user_skills, + "project_skills": build_skills_project_list(projects_by_root), + } + + def _extract_user_level_skills(self, user_skills: List[Dict]) -> None: + """Extract user-level items from ~/.augment/skills and ~/.augment/commands.""" + def extract_for_user(user_home: Path) -> None: + extract_augment_user_level_items( + user_home, user_skills, self._extract_single_rule_file, AUGMENT_ITEM_CONFIGS + ) + + self._scan_all_user_homes(extract_for_user) + + def _extract_project_level_skills(self, root_path: Path, projects_by_root: Dict[str, List[Dict]]) -> None: + """Walk for project-level ``.augment`` skills/commands from the root.""" + if root_path == self._filesystem_root(): + try: + for dir_path in self._iter_top_level_dirs(root_path): + if should_process_directory(dir_path, root_path): + self._walk_for_skills(root_path, dir_path, projects_by_root, current_depth=1) + except (PermissionError, OSError) as e: + logger.warning(f"Error accessing root directory: {e}") + logger.info("Falling back to home directory search for Augment skills") + home_path = Path.home() + self._walk_for_skills(home_path, home_path, projects_by_root, current_depth=0) + else: + self._walk_for_skills(root_path, root_path, projects_by_root, current_depth=0) + + def _walk_for_skills( + self, + root_path: Path, + current_dir: Path, + projects_by_root: Dict[str, List[Dict]], + current_depth: int = 0, + ) -> None: + """Recursively walk looking for ``.augment`` skills/commands dirs. + + Symlinked dirs are skipped; user-home ``~/.augment`` (handled as user + scope) is not double-counted; depth is bounded; never crashes. + """ + if current_depth > MAX_SEARCH_DEPTH: + return + + try: + for item in current_dir.iterdir(): + try: + if self._should_skip_walk_item(item): + continue + + try: + depth = len(item.relative_to(root_path).parts) + if depth > MAX_SEARCH_DEPTH: + continue + except ValueError: + continue + + # Skip non-dirs and symlinked dirs BEFORE the .augment + # handling / recursion (mirrors the rules + mcp + settings + # walk ordering) so a symlinked .augment can't be followed. + if not item.is_dir() or item.is_symlink(): + continue + + if item.name in AUGMENT_PARENT_DIR_NAMES: + for config in AUGMENT_ITEM_CONFIGS: + type_dir = item / config.dir_name + # Guard the skills/commands subdir against symlinks too + # (mirrors the parent .augment guard above): under a + # root MDM scan a user could point .augment/skills at an + # arbitrary dir and have the scanner traverse it. + if ( + type_dir.exists() + and type_dir.is_dir() + and not type_dir.is_symlink() + ): + if not self._is_user_level_skill_dir(type_dir): + extract_augment_items_from_directory( + type_dir, + projects_by_root, + self._extract_single_rule_file, + add_skill_to_project, + config, + ) + continue + + self._walk_for_skills(root_path, item, projects_by_root, current_depth + 1) + + except (PermissionError, OSError): + continue + except Exception as e: + logger.debug(f"Error processing {item}: {e}") + continue + + except (PermissionError, OSError): + pass + except Exception as e: + logger.debug(f"Error walking {current_dir}: {e}") + + # -- OS-specific seams (overridden by the Windows/Linux subclasses) ------- + + def _extract_single_rule_file(self, *args, **kwargs): + """Seam: the OS-specific ``extract_single_rule_file`` (file metadata read).""" + return extract_single_rule_file(*args, **kwargs) + + def _should_skip_walk_item(self, item: Path) -> bool: + """Seam: whether the project walk skips ``item`` (system/skip/other-tool).""" + return ( + should_skip_path(item) + or should_skip_system_path(item) + or traverses_other_tool_config_dir(item) + ) + + def _scan_all_user_homes(self, extract_for_user) -> None: + """Invoke ``extract_for_user(home)`` for every user home (all users when root).""" + if is_running_as_root(): + scan_user_directories(extract_for_user) + else: + extract_for_user(Path.home()) + + def _filesystem_root(self) -> Path: + """Root the project walk starts from (POSIX ``/`` on macOS).""" + return Path("/") + + def _iter_top_level_dirs(self, root_path: Path) -> List[Path]: + """Top-level dirs under the filesystem root, system dirs excluded.""" + return list(get_top_level_directories(root_path)) + + def _is_user_level_skill_dir(self, type_dir: Path) -> bool: + """Whether ``type_dir`` (e.g. ``/.augment/skills``) is a *user-level* + dir the project walk must skip — already reported as user scope.""" + return is_user_level_claude_subdir(type_dir) diff --git a/scripts/coding_discovery_tools/windows/augment/__init__.py b/scripts/coding_discovery_tools/windows/augment/__init__.py new file mode 100644 index 0000000..da162f5 --- /dev/null +++ b/scripts/coding_discovery_tools/windows/augment/__init__.py @@ -0,0 +1,22 @@ +""" +Augment Code detection and extraction for Windows. + +Augment Code (``%USERPROFILE%\\.augment``) ships three surfaces — the Auggie CLI, +the VS Code extension, and the JetBrains plugin — that share one config dir. The +detector and extractors reuse the OS-agnostic macOS logic; only the all-users +(``C:\\Users``) scan and the Windows filesystem primitives are overridden. +""" + +from .augment import WindowsAugmentDetector +from .augment_mcp_config_extractor import WindowsAugmentMCPConfigExtractor +from .augment_rules_extractor import WindowsAugmentRulesExtractor +from .augment_settings_extractor import WindowsAugmentSettingsExtractor +from .augment_skills_extractor import WindowsAugmentSkillsExtractor + +__all__ = [ + 'WindowsAugmentDetector', + 'WindowsAugmentMCPConfigExtractor', + 'WindowsAugmentRulesExtractor', + 'WindowsAugmentSettingsExtractor', + 'WindowsAugmentSkillsExtractor', +] diff --git a/scripts/coding_discovery_tools/windows/augment/augment.py b/scripts/coding_discovery_tools/windows/augment/augment.py new file mode 100644 index 0000000..2ebd205 --- /dev/null +++ b/scripts/coding_discovery_tools/windows/augment/augment.py @@ -0,0 +1,64 @@ +""" +Augment Code detection for Windows. + +Augment Code keeps its config under ``%USERPROFILE%\\.augment`` (i.e. +``~/.augment``), identical to the macOS layout. This subclass inherits the full +macOS detection surface (per-surface CLI/VS Code/JetBrains rows) and overrides +only the OS-specific seams: the all-users (``C:\\Users``) scan, the Windows +``auggie`` binary resolve (npm ``.cmd`` shim, WinGet links, ``.local``/``.bun``), +and the Windows JetBrains detector. The version probe is inherited unchanged +(``run_command``; no ``shell=True``), per the plan. +""" + +import logging +from pathlib import Path +from typing import List, Optional + +from ...windows.jetbrains.jetbrains import WindowsJetBrainsDetector +from ...windows_extraction_helpers import is_running_as_admin +from ...macos.augment.augment import MacOSAugmentDetector + +logger = logging.getLogger(__name__) + + +class WindowsAugmentDetector(MacOSAugmentDetector): + """Detector for Augment Code surfaces on Windows systems.""" + + def _iter_scan_homes(self) -> List[Path]: + """User homes to scan: this user (scoped), else C:\\Users (admin), else home.""" + if self.user_home is not None: + return [self.user_home] + if is_running_as_admin(): + homes: List[Path] = [] + users_dir = Path("C:\\Users") + try: + if users_dir.exists(): + for user_dir in users_dir.iterdir(): + if user_dir.is_dir() and not user_dir.name.startswith("."): + homes.append(user_dir) + except (PermissionError, OSError) as exc: + logger.debug(f"Error scanning C:\\Users for Augment: {exc}") + return homes + return [Path.home()] + + def _resolve_binary(self, user_home: Path) -> Optional[str]: + """Resolve the Windows ``auggie`` binary (npm ``.cmd`` shim / WinGet / etc.).""" + try: + for candidate in ( + user_home / "AppData" / "Roaming" / "npm" / "auggie.cmd", + user_home / "AppData" / "Local" / "Microsoft" / "WinGet" / "Links" / "auggie.exe", + user_home / ".local" / "bin" / "auggie.exe", + user_home / ".bun" / "bin" / "auggie.exe", + ): + try: + if candidate.exists(): + return str(candidate) + except OSError: + continue + except (PermissionError, OSError) as exc: + logger.debug(f"Error resolving Auggie CLI binary for {user_home}: {exc}") + return None + + def _make_jetbrains_detector(self): + """OS seam: the Windows JetBrains detector.""" + return WindowsJetBrainsDetector() diff --git a/scripts/coding_discovery_tools/windows/augment/augment_mcp_config_extractor.py b/scripts/coding_discovery_tools/windows/augment/augment_mcp_config_extractor.py new file mode 100644 index 0000000..c2e5c69 --- /dev/null +++ b/scripts/coding_discovery_tools/windows/augment/augment_mcp_config_extractor.py @@ -0,0 +1,49 @@ +""" +MCP config extraction for Augment Code on Windows systems. + +The parser and the User-scope read are OS-agnostic, so this subclass inherits +them from the macOS extractor. Only the Workspace walk differs on Windows +(drive-letter root + Windows system dirs); we override the +``_workspace_search_roots`` / ``_should_skip_workspace_path`` seams accordingly. +""" + +import logging +from pathlib import Path +from typing import List, Tuple + +from ...macos.augment.augment_mcp_config_extractor import ( + MacOSAugmentMCPConfigExtractor, +) +from ...windows_extraction_helpers import should_skip_path + +logger = logging.getLogger(__name__) + +# Windows system dirs skipped by the workspace walk (mirrors the sibling tools). +_WINDOWS_SYSTEM_DIRS = frozenset({ + "windows", "program files", "program files (x86)", "programdata", + "system volume information", "$recycle.bin", "recovery", + "perflogs", "boot", "system32", "syswow64", "winsxs", + "config.msi", "documents and settings", "msocache", +}) + + +class WindowsAugmentMCPConfigExtractor(MacOSAugmentMCPConfigExtractor): + """Augment MCP extractor on Windows; overrides only the workspace walk.""" + + def _workspace_search_roots(self) -> List[Tuple[Path, Path]]: + """``(root_path, start_dir)`` pairs for the project walk (Windows).""" + root_path = Path(Path.home().anchor or "C:\\") + try: + top_level_dirs = [ + item for item in root_path.iterdir() + if item.is_dir() and not self._should_skip_workspace_path(item) + ] + return [(root_path, top_dir) for top_dir in top_level_dirs] + except (PermissionError, OSError) as exc: + logger.debug(f"Falling back to home for workspace scan: {exc}") + home = Path.home() + return [(home, home)] + + def _should_skip_workspace_path(self, item: Path) -> bool: + """Skip predicate for the Windows workspace walk.""" + return should_skip_path(item) or item.name.lower() in _WINDOWS_SYSTEM_DIRS diff --git a/scripts/coding_discovery_tools/windows/augment/augment_rules_extractor.py b/scripts/coding_discovery_tools/windows/augment/augment_rules_extractor.py new file mode 100644 index 0000000..73cd94f --- /dev/null +++ b/scripts/coding_discovery_tools/windows/augment/augment_rules_extractor.py @@ -0,0 +1,50 @@ +""" +Augment Code rules/guidelines extraction for Windows systems. + +The source set and the depth-bounded walk are OS-agnostic and live in +``MacOSAugmentRulesExtractor``. Only the five OS primitives differ — the +privilege check, the all-users scan, the filesystem root, top-level enumeration, +and the system-dir skip predicate — so this subclass overrides exactly those. +""" + +from pathlib import Path +from typing import List + +from ...constants import traverses_other_tool_config_dir +from ...macos.augment.augment_rules_extractor import MacOSAugmentRulesExtractor +from ...windows_extraction_helpers import ( + get_windows_system_directories, + is_running_as_admin, + scan_windows_user_directories, + should_skip_path, +) + + +class WindowsAugmentRulesExtractor(MacOSAugmentRulesExtractor): + """Augment Code rules extractor on Windows (overrides OS seams only).""" + + def _is_privileged(self) -> bool: + return is_running_as_admin() + + def _scan_all_user_homes(self, extract_for_user) -> None: + scan_windows_user_directories(extract_for_user) + + def _filesystem_root(self) -> Path: + return Path(Path.home().anchor) + + def _iter_top_level_dirs(self, root_path: Path) -> List[Path]: + system_dirs = get_windows_system_directories() + try: + return [ + item + for item in root_path.iterdir() + if item.is_dir() and not should_skip_path(item, system_dirs) + ] + except (PermissionError, OSError): + return [] + + def _should_skip(self, item: Path) -> bool: + return ( + should_skip_path(item, get_windows_system_directories()) + or traverses_other_tool_config_dir(item) + ) diff --git a/scripts/coding_discovery_tools/windows/augment/augment_settings_extractor.py b/scripts/coding_discovery_tools/windows/augment/augment_settings_extractor.py new file mode 100644 index 0000000..7f51f81 --- /dev/null +++ b/scripts/coding_discovery_tools/windows/augment/augment_settings_extractor.py @@ -0,0 +1,26 @@ +""" +Augment Code settings/permissions extraction for Windows. + +The settings/permissions parsing is OS-agnostic and inherited from +``MacOSAugmentSettingsExtractor``. Only the OS seams differ: the all-users scan +and the managed-settings path (``C:\\ProgramData\\augment\\settings.json``). +""" + +from pathlib import Path + +from ...macos.augment.augment_settings_extractor import ( + MacOSAugmentSettingsExtractor, +) +from ...windows_extraction_helpers import ( + scan_windows_user_directories, +) + + +class WindowsAugmentSettingsExtractor(MacOSAugmentSettingsExtractor): + """Augment Code settings extractor on Windows (overrides OS seams only).""" + + def _user_settings_scan(self, extract_for_user) -> None: + scan_windows_user_directories(extract_for_user) + + def _managed_settings_path(self) -> Path: + return Path("C:\\ProgramData\\augment\\settings.json") diff --git a/scripts/coding_discovery_tools/windows/augment/augment_skills_extractor.py b/scripts/coding_discovery_tools/windows/augment/augment_skills_extractor.py new file mode 100644 index 0000000..f34662d --- /dev/null +++ b/scripts/coding_discovery_tools/windows/augment/augment_skills_extractor.py @@ -0,0 +1,64 @@ +""" +Augment Code skills/commands extraction for Windows systems. + +The per-tool config and project-grouping logic are OS-agnostic and inherited from +``MacOSAugmentSkillsExtractor`` (single-threaded, no thread pool — per plan). Only +the OS primitives are overridden via seams: the file-metadata read, the +walk-skip predicate, the all-users scan, the filesystem root, the top-level +enumeration, and the user-level-dir check. +""" + +from pathlib import Path +from typing import List + +from ...constants import traverses_other_tool_config_dir +from ...macos.augment.augment_skills_extractor import MacOSAugmentSkillsExtractor +from ...windows_extraction_helpers import ( + extract_single_rule_file, + get_windows_system_directories, + scan_windows_user_directories, + should_skip_path, +) +from ...claude_code_skills_helpers import is_user_level_claude_subdir + + +class WindowsAugmentSkillsExtractor(MacOSAugmentSkillsExtractor): + """Augment Code skills extractor on Windows (single-threaded; OS seams only).""" + + def __init__(self) -> None: + super().__init__() + self._users_directory = str(Path.home().parent) + + def _extract_single_rule_file(self, *args, **kwargs): + return extract_single_rule_file(*args, **kwargs) + + def _should_skip_walk_item(self, item: Path) -> bool: + # Match the macOS base + the Windows RULES extractor + the Copilot + # Windows skills walk: also skip other-tool config dirs (``~/.``) + # so the walk does not descend into another tool's bundled config. The + # ``.augment`` dir is NOT in OTHER_TOOL_CONFIG_DIRS, so it stays + # traversable. + return ( + should_skip_path(item, get_windows_system_directories()) + or traverses_other_tool_config_dir(item) + ) + + def _scan_all_user_homes(self, extract_for_user) -> None: + scan_windows_user_directories(extract_for_user) + + def _filesystem_root(self) -> Path: + return Path(Path.home().anchor) + + def _iter_top_level_dirs(self, root_path: Path) -> List[Path]: + system_dirs = get_windows_system_directories() + try: + return [ + item + for item in root_path.iterdir() + if item.is_dir() and not should_skip_path(item, system_dirs) + ] + except (PermissionError, OSError): + return [] + + def _is_user_level_skill_dir(self, type_dir: Path) -> bool: + return is_user_level_claude_subdir(type_dir, self._users_directory) diff --git a/tests/test_augment_discovery.py b/tests/test_augment_discovery.py new file mode 100644 index 0000000..7476106 --- /dev/null +++ b/tests/test_augment_discovery.py @@ -0,0 +1,463 @@ +""" +Integration tests for Augment Code discovery (macOS + Windows/Linux smoke). + +Augment Code ships three surfaces — the Auggie CLI, the VS Code extension, and the +JetBrains plugin — that share one ``~/.augment`` config dir. These tests exercise +the outermost surfaces: + + - The detector's ``detect()`` emits a separate row per surface. + - ``_resolve_augment_dir`` default + ``_parse_cli_version`` cases. + - ``AIToolsDetector.process_single_tool`` routing — the Augment branch wins over + the generic JetBrains ``_config_path`` fallback (R2). + - The MCP extractor reads both the top-level and nested ``mcpServers`` nestings. + - The Windows + Linux subclasses import/instantiate. + +Conventions mirror the existing suite: temp HOME dirs, the globally-stubbed MCP +scanner (``tests/__init__.py``), ``_SENTRY_DSN`` forced empty. +""" + +import json +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +import scripts.coding_discovery_tools.utils as utils_mod +from scripts.coding_discovery_tools.ai_tools_discovery import AIToolsDetector +from scripts.coding_discovery_tools.coding_tool_factory import ( + AugmentMCPConfigExtractorFactory, + ToolDetectorFactory, +) +from scripts.coding_discovery_tools.macos.augment.augment import ( + MacOSAugmentDetector, + _parse_cli_version, + _resolve_auggie_binary, + _resolve_augment_dir, +) +from scripts.coding_discovery_tools.macos.augment.augment_mcp_config_extractor import ( + MacOSAugmentMCPConfigExtractor, + _extract_servers_obj, +) +from scripts.coding_discovery_tools.windows.augment.augment import WindowsAugmentDetector +from scripts.coding_discovery_tools.windows.augment.augment_mcp_config_extractor import ( + WindowsAugmentMCPConfigExtractor, +) +from scripts.coding_discovery_tools.windows.augment.augment_settings_extractor import ( + WindowsAugmentSettingsExtractor, +) +from scripts.coding_discovery_tools.windows.augment.augment_skills_extractor import ( + WindowsAugmentSkillsExtractor, +) +from scripts.coding_discovery_tools.linux.augment.augment import LinuxAugmentDetector +from scripts.coding_discovery_tools.linux.augment.augment_mcp_config_extractor import ( + LinuxAugmentMCPConfigExtractor, +) +from scripts.coding_discovery_tools.linux.augment.augment_skills_extractor import ( + LinuxAugmentSkillsExtractor, +) + +_DETECTOR_MOD = "scripts.coding_discovery_tools.macos.augment.augment" + + +def _write_auggie_binary(user_home: Path) -> Path: + """Drop an executable ``~/.local/bin/auggie`` under ``user_home``.""" + binary = user_home / ".local" / "bin" / "auggie" + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_text("#!/bin/sh\necho auggie\n", encoding="utf-8") + os.chmod(binary, 0o755) + return binary + + +# --------------------------------------------------------------------------- +# 1. Version parsing + config-dir resolution (focused unit tests) +# --------------------------------------------------------------------------- + +class TestAugmentParsing(unittest.TestCase): + def test_parse_cli_version_semver_from_banner(self): + self.assertEqual(_parse_cli_version("0.30.0 (commit 690bba03)"), "0.30.0") + + def test_parse_cli_version_plain(self): + self.assertEqual(_parse_cli_version("1.2.3"), "1.2.3") + + def test_parse_cli_version_none_and_garbage(self): + self.assertIsNone(_parse_cli_version(None)) + self.assertIsNone(_parse_cli_version("")) + # No semver -> falls back to first non-empty line (capped). + self.assertEqual(_parse_cli_version("auggie dev build"), "auggie dev build") + + def test_resolve_augment_dir_default(self): + home = Path("/Users/alice") + self.assertEqual(_resolve_augment_dir(home), home / ".augment") + + +# --------------------------------------------------------------------------- +# 2. Detection: separate rows per surface +# --------------------------------------------------------------------------- + +class TestAugmentDetection(unittest.TestCase): + def setUp(self): + utils_mod._SENTRY_DSN = "" + self.detector = MacOSAugmentDetector() + self.tmp_dir = tempfile.mkdtemp() + self.user_home = Path(self.tmp_dir) / "user" + self.user_home.mkdir(parents=True) + self.detector.user_home = self.user_home + self._patchers = [ + patch(f"{_DETECTOR_MOD}.is_running_as_root", return_value=False), + ] + for p in self._patchers: + p.start() + + def tearDown(self): + for p in self._patchers: + p.stop() + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def _write_vscode_ext(self, ext_id="augment.vscode-augment", version="1.5.0"): + ext_path = self.user_home / ".vscode" / "extensions" / "extensions.json" + ext_path.parent.mkdir(parents=True, exist_ok=True) + ext_path.write_text(json.dumps([ + {"identifier": {"id": ext_id}, "version": version}, + ]), encoding="utf-8") + return ext_path + + def test_cli_row_gated_on_binary(self): + binary = _write_auggie_binary(self.user_home) + with patch.object(self.detector, "get_version", return_value=None): + rows = self.detector.detect() + self.assertIsNotNone(rows) + cli = [r for r in rows if r["name"] == "Auggie CLI"] + self.assertEqual(len(cli), 1) + self.assertEqual(cli[0]["install_path"], str(binary)) + self.assertEqual(cli[0]["publisher"], "Augment Computer") + self.assertEqual(cli[0]["_config_path"], str(self.user_home / ".augment")) + self.assertEqual(cli[0]["version"], "unknown") + + def test_no_binary_no_cli_row(self): + # No auggie binary and no VS Code/JetBrains -> no rows at all. + with patch.object(self.detector, "_make_jetbrains_detector") as jb: + jb.return_value.detect.return_value = [] + self.assertIsNone(self.detector.detect()) + + def test_vscode_row_from_extensions_json(self): + self._write_vscode_ext(version="2.0.1") + with patch.object(self.detector, "_make_jetbrains_detector") as jb: + jb.return_value.detect.return_value = [] + rows = self.detector.detect() + vsc = [r for r in rows if r["name"] == "Augment (VS Code)"] + self.assertEqual(len(vsc), 1) + self.assertEqual(vsc[0]["version"], "2.0.1") + self.assertEqual(vsc[0]["publisher"], "Augment Computer") + + def _write_vscode_exts(self, exts): + """Write multiple extensions: ``exts`` is a list of (ext_id, version).""" + ext_path = self.user_home / ".vscode" / "extensions" / "extensions.json" + ext_path.parent.mkdir(parents=True, exist_ok=True) + ext_path.write_text(json.dumps([ + {"identifier": {"id": ext_id}, "version": version} + for ext_id, version in exts + ]), encoding="utf-8") + return ext_path + + def test_vscode_nightly_id_matched(self): + self._write_vscode_ext(ext_id="augment.vscode-augment-nightly", version="2.0.1-nightly") + with patch.object(self.detector, "_make_jetbrains_detector") as jb: + jb.return_value.detect.return_value = [] + rows = self.detector.detect() + vsc = [r for r in rows if r["name"] == "Augment (VS Code)"] + # FIX C: nightly-only -> one row with nightly's version. + self.assertEqual(len(vsc), 1) + self.assertEqual(vsc[0]["version"], "2.0.1-nightly") + + def test_vscode_stable_and_nightly_emit_single_stable_row(self): + """FIX C: both stable + nightly installed -> exactly ONE "Augment (VS Code)" + row, carrying the STABLE extension's version (preferred over nightly).""" + self._write_vscode_exts([ + ("augment.vscode-augment-nightly", "2.0.1-nightly"), + ("augment.vscode-augment", "1.5.0"), + ]) + with patch.object(self.detector, "_make_jetbrains_detector") as jb: + jb.return_value.detect.return_value = [] + rows = self.detector.detect() + vsc = [r for r in rows if r["name"] == "Augment (VS Code)"] + self.assertEqual(len(vsc), 1) + self.assertEqual(vsc[0]["version"], "1.5.0") + + def test_corrupt_extensions_json_yields_no_vscode_row(self): + ext_path = self.user_home / ".vscode" / "extensions" / "extensions.json" + ext_path.parent.mkdir(parents=True, exist_ok=True) + ext_path.write_text("{ not json", encoding="utf-8") + with patch.object(self.detector, "_make_jetbrains_detector") as jb: + jb.return_value.detect.return_value = [] + rows = self.detector.detect() or [] + self.assertEqual([r for r in rows if r["name"] == "Augment (VS Code)"], []) + + def test_jetbrains_row_when_plugin_matches(self): + fake_ide = { + "name": "IntelliJ IDEA", + "version": "2024.1", + "plugins": ["Augment", "Some Other Plugin"], + "config_path": "/cfg/IntelliJIdea2024.1", + "install_path": "/cfg/IntelliJIdea2024.1", + } + with patch.object(self.detector, "_make_jetbrains_detector") as jb: + jb.return_value.detect.return_value = [fake_ide] + rows = self.detector.detect() + jbrows = [r for r in rows if r["name"] == "Augment (IntelliJ IDEA)"] + self.assertEqual(len(jbrows), 1) + self.assertEqual(jbrows[0]["ide"], "IntelliJ IDEA") + self.assertEqual(jbrows[0]["version"], "2024.1") + self.assertEqual(jbrows[0]["_config_path"], str(self.user_home / ".augment")) + + def test_jetbrains_no_row_when_no_augment_plugin(self): + fake_ide = { + "name": "PyCharm", + "version": "2024.1", + "plugins": ["GitHub Copilot"], + "config_path": "/cfg/PyCharm2024.1", + "install_path": "/cfg/PyCharm2024.1", + } + with patch.object(self.detector, "_make_jetbrains_detector") as jb: + jb.return_value.detect.return_value = [fake_ide] + rows = self.detector.detect() or [] + self.assertEqual([r for r in rows if r["name"].startswith("Augment (")], []) + + def test_jetbrains_root_scan_attributes_config_to_ide_owner(self): + """FIX H2: under a root all-users scan the JetBrains detector returns + EVERY user's IDEs; each Augment JetBrains row's ``_config_path`` must point + at the IDE OWNER's ``~/.augment`` (derived from the IDE config path), not + the outer scan home.""" + self.detector.user_home = None # simulate a root all-users scan + bob_ide = { + "name": "IntelliJ IDEA", + "version": "2024.1", + "plugins": ["Augment"], + "config_path": "/Users/bob/Library/Application Support/JetBrains/IntelliJIdea2024.1", + "install_path": "/Users/bob/Library/Application Support/JetBrains/IntelliJIdea2024.1", + } + with patch.object(self.detector, "_iter_scan_homes", + return_value=[Path("/Users/alice"), Path("/Users/bob")]), \ + patch.object(self.detector, "_make_jetbrains_detector") as jb: + jb.return_value.detect.return_value = [bob_ide] + rows = self.detector.detect() or [] + jbrows = [r for r in rows if r["name"] == "Augment (IntelliJ IDEA)"] + self.assertEqual(len(jbrows), 1) + # Attributed to bob (the IDE owner), NOT alice (the other scanned home). + # Build the expected via Path so the separator matches the host OS + # (production stringifies a Path; a literal "/Users/..." breaks on Windows). + self.assertEqual(jbrows[0]["_config_path"], str(Path("/Users/bob") / ".augment")) + + def test_all_three_surfaces_as_separate_rows(self): + _write_auggie_binary(self.user_home) + self._write_vscode_ext() + fake_ide = { + "name": "GoLand", "version": "2024.1", "plugins": ["augment-jetbrains"], + "config_path": "/cfg/GoLand", "install_path": "/cfg/GoLand", + } + with patch.object(self.detector, "get_version", return_value=None), \ + patch.object(self.detector, "_make_jetbrains_detector") as jb: + jb.return_value.detect.return_value = [fake_ide] + rows = self.detector.detect() + names = sorted(r["name"] for r in rows) + self.assertEqual(names, ["Auggie CLI", "Augment (GoLand)", "Augment (VS Code)"]) + + def test_detect_never_raises_on_jetbrains_error(self): + _write_auggie_binary(self.user_home) + with patch.object(self.detector, "get_version", return_value=None), \ + patch.object(self.detector, "_make_jetbrains_detector") as jb: + jb.return_value.detect.side_effect = OSError("boom") + rows = self.detector.detect() + # CLI row still surfaces; the JetBrains error is swallowed. + self.assertTrue(any(r["name"] == "Auggie CLI" for r in rows)) + + +# --------------------------------------------------------------------------- +# 3. Routing: Augment branch wins over the JetBrains _config_path fallback (R2) +# --------------------------------------------------------------------------- + +class TestAugmentRouting(unittest.TestCase): + def setUp(self): + utils_mod._SENTRY_DSN = "" + self.detector = AIToolsDetector(os_name="Darwin") + # Stub the shared extractors so process_single_tool runs no real walk. + self.detector._augment_mcp_extractor = MagicMock() + self.detector._augment_mcp_extractor.extract_mcp_config.return_value = None + self.detector._augment_rules_extractor = MagicMock() + self.detector._augment_rules_extractor.extract_all_augment_rules.return_value = [] + self.detector._augment_skills_extractor = MagicMock() + self.detector._augment_skills_extractor.extract_all_skills.return_value = { + "user_skills": [], "project_skills": [], + } + self.detector._augment_settings_extractor = MagicMock() + self.detector._augment_settings_extractor.extract_settings.return_value = [] + + def test_auggie_cli_routes_to_augment_branch(self): + tool = {"name": "Auggie CLI", "version": "0.30.0", + "install_path": "/Users/x/.local/bin/auggie", "_config_path": "/Users/x/.augment"} + self.detector._set_canonical_augment_surface([tool]) + sentinel = {"routed": "augment"} + with patch.object(self.detector, "_process_augment_tool", return_value=sentinel) as branch: + result = self.detector.process_single_tool(tool) + self.assertEqual(result, sentinel) + self.assertEqual(branch.call_count, 1) + + def test_vscode_augment_routes_to_augment_branch(self): + tool = {"name": "Augment (VS Code)", "version": "1.0", + "install_path": "/x", "_config_path": "/Users/x/.augment"} + self.detector._set_canonical_augment_surface([tool]) + sentinel = {"routed": "augment"} + with patch.object(self.detector, "_process_augment_tool", return_value=sentinel) as branch: + result = self.detector.process_single_tool(tool) + self.assertEqual(result, sentinel) + self.assertEqual(branch.call_count, 1) + + def test_jetbrains_augment_does_not_fall_into_jetbrains_branch(self): + """R2 regression: an Augment JetBrains row carries ``_config_path`` and must + route to the Augment branch, NOT the generic JetBrains handler.""" + tool = {"name": "Augment (IntelliJ IDEA)", "version": "2024.1", "ide": "IntelliJ IDEA", + "install_path": "/cfg", "_config_path": "/Users/x/.augment"} + self.detector._set_canonical_augment_surface([tool]) + with patch.object(self.detector, "_process_augment_tool", return_value={"routed": "augment"}) as aug, \ + patch.object(self.detector, "_process_jetbrains_tool", return_value={}) as jb: + result = self.detector.process_single_tool(tool) + self.assertEqual(result, {"routed": "augment"}) + self.assertEqual(aug.call_count, 1) + self.assertEqual(jb.call_count, 0) + + def test_real_jetbrains_ide_still_routes_to_jetbrains_branch(self): + """A non-Augment JetBrains IDE row must still take the JetBrains branch.""" + tool = {"name": "PyCharm", "version": "2024.1", "_config_path": "/cfg/PyCharm", + "_ide_folder": "PyCharm2024.1"} + with patch.object(self.detector, "_process_augment_tool") as aug, \ + patch.object(self.detector, "_process_jetbrains_tool", return_value={}) as jb: + self.detector.process_single_tool(tool) + self.assertEqual(aug.call_count, 0) + self.assertEqual(jb.call_count, 1) + + +# --------------------------------------------------------------------------- +# 4. MCP: reads both top-level and nested mcpServers nestings +# --------------------------------------------------------------------------- + +class TestAugmentMCPNestings(unittest.TestCase): + def test_extract_servers_top_level(self): + data = {"mcpServers": {"srv": {"command": "node"}}} + self.assertEqual(_extract_servers_obj(data), {"srv": {"command": "node"}}) + + def test_extract_servers_nested_augment_advanced(self): + data = {"augment": {"advanced": {"mcpServers": {"srv": {"command": "node"}}}}} + self.assertEqual(_extract_servers_obj(data), {"srv": {"command": "node"}}) + + def test_extract_servers_flat_form(self): + data = {"srv": {"command": "node"}, "metadata": "ignored"} + self.assertEqual(_extract_servers_obj(data), {"srv": {"command": "node"}}) + + def test_top_level_wins_over_nested(self): + data = { + "mcpServers": {"top": {"command": "a"}}, + "augment": {"advanced": {"mcpServers": {"nested": {"command": "b"}}}}, + } + self.assertEqual(_extract_servers_obj(data), {"top": {"command": "a"}}) + + def test_user_config_reads_nested_nesting(self): + tmp = tempfile.mkdtemp() + try: + home = Path(tmp) / "user" + augment_dir = home / ".augment" + augment_dir.mkdir(parents=True) + (augment_dir / "settings.json").write_text(json.dumps({ + "augment": {"advanced": {"mcpServers": {"db": {"command": "mcp-db"}}}}, + }), encoding="utf-8") + extractor = MacOSAugmentMCPConfigExtractor() + configs = extractor._extract_user_configs_for_user(home) + self.assertEqual(len(configs), 1) + self.assertEqual(configs[0]["path"], str(augment_dir)) + self.assertEqual(len(configs[0]["mcpServers"]), 1) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_workspace_walk_skips_user_augment_dir(self): + """A user-home ``~/.augment`` already collected as USER scope must NOT be + re-collected by the workspace walk as PROJECT scope — otherwise the same + MCP servers are duplicated under two project paths (Greptile finding). A + genuine project ``.augment`` is still collected.""" + tmp = tempfile.mkdtemp() + try: + root = Path(tmp) + user_augment = root / "home" / ".augment" + user_augment.mkdir(parents=True) + (user_augment / "settings.json").write_text(json.dumps({ + "mcpServers": {"db": {"command": "mcp-db"}}, + }), encoding="utf-8") + proj_augment = root / "repo" / ".augment" + proj_augment.mkdir(parents=True) + (proj_augment / "settings.json").write_text(json.dumps({ + "mcpServers": {"proj": {"command": "mcp-proj"}}, + }), encoding="utf-8") + + extractor = MacOSAugmentMCPConfigExtractor() + # Pretend the user-home ~/.augment was already collected as USER scope. + extractor._scanned_user_augment_dirs = {user_augment.resolve()} + + projects = [] + # Drive the walk from the temp ancestor (current_depth=0 avoids the + # relative_to('/') path that breaks on Windows) with the system-skip + # predicate neutralised for the temp tree. + with patch.object(extractor, "_should_skip_workspace_path", return_value=False): + extractor._walk_for_workspace_configs(root, root, projects, current_depth=0) + + paths = {p["path"] for p in projects} + # User-home .augment SKIPPED (no project entry); the real repo kept. + self.assertNotIn(str(user_augment.parent), paths) + self.assertIn(str(proj_augment.parent), paths) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# 5. Windows + Linux subclasses import/instantiate (smoke) +# --------------------------------------------------------------------------- + +class TestAugmentCrossPlatformSmoke(unittest.TestCase): + def test_factory_creates_per_os(self): + for os_name in ("Darwin", "Windows", "Linux"): + self.assertIsNotNone(ToolDetectorFactory.create_augment_detector(os_name)) + self.assertIsNotNone(AugmentMCPConfigExtractorFactory.create(os_name)) + + def test_windows_subclasses_instantiate(self): + self.assertEqual(WindowsAugmentDetector().tool_name, "Augment Code") + self.assertIsInstance(WindowsAugmentMCPConfigExtractor(), MacOSAugmentMCPConfigExtractor) + WindowsAugmentSettingsExtractor() + WindowsAugmentSkillsExtractor() + + def test_linux_subclasses_instantiate(self): + self.assertEqual(LinuxAugmentDetector().tool_name, "Augment Code") + self.assertIsInstance(LinuxAugmentMCPConfigExtractor(), MacOSAugmentMCPConfigExtractor) + LinuxAugmentSkillsExtractor() + + def test_windows_skills_skip_predicate_parity(self): + """FIX E: the Windows skills walk-skip predicate must, like the macOS base + and the Windows RULES extractor, skip other-tool config dirs (``~/.``) + while still descending into ``.augment``. + + Paths are built from separate components (not a backslash literal) so + ``.parts`` splits correctly on BOTH the macOS and Windows CI runners. + """ + extractor = WindowsAugmentSkillsExtractor() + anchor = Path(Path.home().anchor) + # Other-tool config dirs are skipped (no descent into another tool's bundle). + self.assertTrue( + extractor._should_skip_walk_item(anchor / "Users" / "alice" / ".cursor")) + self.assertTrue( + extractor._should_skip_walk_item( + anchor / "Users" / "alice" / "repo" / ".claude" / "skills")) + # ``.augment`` itself must NOT be skipped (it must stay traversable). + self.assertFalse( + extractor._should_skip_walk_item( + anchor / "Users" / "alice" / "repo" / ".augment")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_augment_overcollection.py b/tests/test_augment_overcollection.py new file mode 100644 index 0000000..6233c58 --- /dev/null +++ b/tests/test_augment_overcollection.py @@ -0,0 +1,422 @@ +""" +Over-collection tests for Augment Code (macOS). + +Augment ships three surfaces that share one ``~/.augment`` config. These tests +prove the shared config is attached to EXACTLY ONE (canonical) surface row, the +others stay bare, the canonical fallback order is CLI > VS Code > JetBrains, and +per-user attribution under a simulated root scan does not leak across users. +""" + +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +import scripts.coding_discovery_tools.utils as utils_mod +from scripts.coding_discovery_tools.ai_tools_discovery import ( + AIToolsDetector, + _augment_owned_by_user, +) + + +def _stub_detector(): + d = AIToolsDetector(os_name="Darwin") + d._augment_mcp_extractor = MagicMock() + d._augment_mcp_extractor.extract_mcp_config.return_value = { + "projects": [{"path": "/Users/x/.augment", "mcpServers": [{"name": "srv"}], "scope": "user"}], + } + d._augment_rules_extractor = MagicMock() + d._augment_rules_extractor.extract_all_augment_rules.return_value = [ + {"project_root": "/Users/x/.augment", + "rules": [{"file_path": "/Users/x/.augment/user-guidelines.md", "file_name": "user-guidelines.md"}]}, + ] + d._augment_skills_extractor = MagicMock() + d._augment_skills_extractor.extract_all_skills.return_value = {"user_skills": [], "project_skills": []} + d._augment_settings_extractor = MagicMock() + d._augment_settings_extractor.extract_settings.return_value = [ + {"tool_name": "Augment Code", "scope": "user", + "settings_path": "/Users/x/.augment/settings.json", + "raw_settings": {"toolPermissions": []}, + "permissions": {"defaultMode": None, "allow": ["read"], "deny": [], "ask": [], + "additionalDirectories": []}}, + ] + return d + + +_CLI = {"name": "Auggie CLI", "version": "0.30.0", + "install_path": "/Users/x/.local/bin/auggie", "_config_path": "/Users/x/.augment"} +_VSC = {"name": "Augment (VS Code)", "version": "1.0", + "install_path": "/Users/x/.vscode/extensions", "_config_path": "/Users/x/.augment"} +_JB = {"name": "Augment (IntelliJ IDEA)", "version": "2024.1", "ide": "IntelliJ IDEA", + "install_path": "/cfg", "_config_path": "/Users/x/.augment"} + + +class TestAugmentCanonicalSplit(unittest.TestCase): + def setUp(self): + utils_mod._SENTRY_DSN = "" + + def test_shared_config_on_exactly_one_row(self): + d = _stub_detector() + tools = [_CLI, _VSC, _JB] + d._set_canonical_augment_surface(tools) + results = {t["name"]: d.process_single_tool(t) for t in tools} + + # Canonical = Auggie CLI carries the shared config + permissions. + cli = results["Auggie CLI"] + self.assertTrue(cli["projects"]) + self.assertIn("permissions", cli) + + # The other two are bare: no projects, no permissions. + for name in ("Augment (VS Code)", "Augment (IntelliJ IDEA)"): + self.assertEqual(results[name]["projects"], []) + self.assertNotIn("permissions", results[name]) + + def test_canonical_fallback_vscode_when_cli_absent(self): + d = _stub_detector() + tools = [_VSC, _JB] + d._set_canonical_augment_surface(tools) + self.assertEqual( + d._canonical_augment_surface_by_config["/Users/x/.augment"], + "augment (vs code)", + ) + results = {t["name"]: d.process_single_tool(t) for t in tools} + self.assertTrue(results["Augment (VS Code)"]["projects"]) + self.assertEqual(results["Augment (IntelliJ IDEA)"]["projects"], []) + + def test_canonical_fallback_jetbrains_when_cli_and_vscode_absent(self): + d = _stub_detector() + tools = [_JB] + d._set_canonical_augment_surface(tools) + self.assertEqual( + d._canonical_augment_surface_by_config["/Users/x/.augment"], + "augment (intellij idea)", + ) + result = d.process_single_tool(_JB) + self.assertTrue(result["projects"]) + # JetBrains canonical row keeps its ``ide`` key but routed via Augment. + self.assertEqual(result["ide"], "IntelliJ IDEA") + + def test_no_augment_surface_canonical_is_empty(self): + d = _stub_detector() + d._set_canonical_augment_surface([{"name": "Cursor"}]) + self.assertEqual(d._canonical_augment_surface_by_config, {}) + + def test_memoized_extractors_run_once(self): + """Even with three surfaces processed, the canonical row drives extraction; + the shared walks run at most once per scan via the memo caches.""" + d = _stub_detector() + tools = [_CLI, _VSC, _JB] + d._set_canonical_augment_surface(tools) + for t in tools: + d.process_single_tool(t) + # Only the canonical surface invokes the extractors, and the memo cache + # collapses repeats — so at most one call each. + self.assertEqual(d._augment_rules_extractor.extract_all_augment_rules.call_count, 1) + self.assertEqual(d._augment_skills_extractor.extract_all_skills.call_count, 1) + self.assertEqual(d._augment_mcp_extractor.extract_mcp_config.call_count, 1) + + +class TestAugmentPerUserAttribution(unittest.TestCase): + """The ~/.augment-keyed ownership gate scopes a canonical row to its owner.""" + + def test_owner_passes_gate(self): + tool_filtered = {"name": "Auggie CLI", "_config_path": "/Users/alice/.augment", + "install_path": "/Users/alice/.local/bin/auggie", "projects": []} + self.assertTrue(_augment_owned_by_user(tool_filtered, "/Users/alice")) + + def test_non_owner_with_no_data_fails_gate(self): + # bob is scanned but the config dir is alice's and there's no per-user data. + tool_filtered = {"name": "Auggie CLI", "_config_path": "/Users/alice/.augment", + "install_path": "/Users/alice/.local/bin/auggie", "projects": []} + self.assertFalse(_augment_owned_by_user(tool_filtered, "/Users/bob")) + + def test_non_owner_with_per_user_data_passes_gate(self): + # A user with per-user project data is kept even if the config dir differs. + tool_filtered = {"name": "Auggie CLI", "_config_path": "/Users/alice/.augment", + "install_path": "/Users/alice/.local/bin/auggie", + "projects": [{"path": "/Users/bob/repo", "rules": [{"x": 1}]}]} + self.assertTrue(_augment_owned_by_user(tool_filtered, "/Users/bob")) + + +class TestAugmentPerUserCanonical(unittest.TestCase): + """FIX G: canonical surface is chosen PER USER (keyed by ``_config_path``). + + The old single global-string model would mark only CLI rows canonical when ANY + user had the CLI — so a DIFFERENT user with VS Code ONLY got a bare row and lost + their config. These assert each user's config is independently canonicalised. + """ + + def setUp(self): + utils_mod._SENTRY_DSN = "" + + def _multi_user_detector(self): + """Root scan: user A has CLI + VS Code under configA; user B has VS Code + ONLY under configB. The shared extractors return per-config data.""" + d = AIToolsDetector(os_name="Darwin") + d._augment_mcp_extractor = MagicMock() + d._augment_mcp_extractor.extract_mcp_config.return_value = None + d._augment_rules_extractor = MagicMock() + # Rules for BOTH users, each keyed under their own ~/.augment. + d._augment_rules_extractor.extract_all_augment_rules.return_value = [ + {"project_root": "/Users/alice/.augment", + "rules": [{"file_path": "/Users/alice/.augment/user-guidelines.md", + "file_name": "user-guidelines.md"}]}, + {"project_root": "/Users/bob/.augment", + "rules": [{"file_path": "/Users/bob/.augment/user-guidelines.md", + "file_name": "user-guidelines.md"}]}, + ] + d._augment_skills_extractor = MagicMock() + d._augment_skills_extractor.extract_all_skills.return_value = { + "user_skills": [], "project_skills": []} + d._augment_settings_extractor = MagicMock() + d._augment_settings_extractor.extract_settings.return_value = [] + return d + + def test_each_user_gets_own_canonical_surface(self): + d = self._multi_user_detector() + a_cli = {"name": "Auggie CLI", "version": "0.30.0", + "install_path": "/Users/alice/.local/bin/auggie", + "_config_path": "/Users/alice/.augment"} + a_vsc = {"name": "Augment (VS Code)", "version": "1.0", + "install_path": "/Users/alice/.vscode/extensions", + "_config_path": "/Users/alice/.augment"} + b_vsc = {"name": "Augment (VS Code)", "version": "1.0", + "install_path": "/Users/bob/.vscode/extensions", + "_config_path": "/Users/bob/.augment"} + tools = [a_cli, a_vsc, b_vsc] + d._set_canonical_augment_surface(tools) + + # Per-config canonical: A -> CLI, B -> VS Code (independent winners). + self.assertEqual( + d._canonical_augment_surface_by_config["/Users/alice/.augment"], "auggie cli") + self.assertEqual( + d._canonical_augment_surface_by_config["/Users/bob/.augment"], "augment (vs code)") + + results = [d.process_single_tool(t) for t in tools] + by = {(r["name"], r["_config_path"]): r for r in results} + + # A's CLI carries A's config; A's VS Code is bare. + self.assertTrue(by[("Auggie CLI", "/Users/alice/.augment")]["projects"]) + self.assertEqual(by[("Augment (VS Code)", "/Users/alice/.augment")]["projects"], []) + + # B's VS Code is CANONICAL and carries B's config (NOT dropped). + b_row = by[("Augment (VS Code)", "/Users/bob/.augment")] + self.assertTrue(b_row["projects"]) + b_roots = {p["path"] for p in b_row["projects"]} + self.assertIn("/Users/bob/.augment", b_roots) + + def test_cross_user_isolation_holds(self): + d = self._multi_user_detector() + a_cli = {"name": "Auggie CLI", "version": "0.30.0", + "install_path": "/Users/alice/.local/bin/auggie", + "_config_path": "/Users/alice/.augment"} + b_vsc = {"name": "Augment (VS Code)", "version": "1.0", + "install_path": "/Users/bob/.vscode/extensions", + "_config_path": "/Users/bob/.augment"} + tools = [a_cli, b_vsc] + d._set_canonical_augment_surface(tools) + + a_processed = d.process_single_tool(a_cli) + b_processed = d.process_single_tool(b_vsc) + + # A's config never appears on B's row and vice-versa, even after the + # per-user project filter. + a_view = d.filter_tool_projects_by_user(a_processed, Path("/Users/alice")) + b_view = d.filter_tool_projects_by_user(b_processed, Path("/Users/bob")) + a_roots = {p["path"] for p in a_view["projects"]} + b_roots = {p["path"] for p in b_view["projects"]} + self.assertNotIn("/Users/bob/.augment", a_roots) + self.assertNotIn("/Users/alice/.augment", b_roots) + + +class TestAugmentMcpCacheSentinel(unittest.TestCase): + """FIX B: ``extract_mcp_config`` returning None must still memoize. A distinct + UNSET sentinel (not None) drives the cache, so a cached None short-circuits and + the expensive MCP walk runs only ONCE across many ``_get_augment_mcp`` calls.""" + + def setUp(self): + utils_mod._SENTRY_DSN = "" + + def test_none_result_memoized_extract_called_once(self): + d = AIToolsDetector(os_name="Darwin") + d._augment_mcp_extractor = MagicMock() + d._augment_mcp_extractor.extract_mcp_config.return_value = None + + for _ in range(5): + self.assertIsNone(d._get_augment_mcp()) + + self.assertEqual(d._augment_mcp_extractor.extract_mcp_config.call_count, 1) + + +class TestAugmentManagedOnlyOwnership(unittest.TestCase): + """FIX F: managed-scope-only permissions must NOT manufacture an Augment row + for a non-owner. Managed (org-wide /etc/augment) policy survives filtering for + EVERY user, so its mere presence cannot count as user-owned data.""" + + def test_managed_only_perms_do_not_make_non_owner_owned(self): + # alice owns ~/.augment; the surviving permissions block is MANAGED only. + # bob, a non-owner with no per-user data, must NOT pass the gate. + tool_filtered = { + "name": "Auggie CLI", "_config_path": "/Users/alice/.augment", + "install_path": "/Users/alice/.local/bin/auggie", "projects": [], + "permissions": {"settings_source": "managed", "scope": "managed", + "settings_path": "/etc/augment/settings.json"}, + } + self.assertFalse(_augment_owned_by_user(tool_filtered, "/Users/bob")) + + def test_managed_perms_still_owned_by_config_owner(self): + # The config owner (alice) is still owned — via owns_install — even when the + # only attached permissions are managed (effective org policy). + tool_filtered = { + "name": "Auggie CLI", "_config_path": "/Users/alice/.augment", + "install_path": "/Users/alice/.local/bin/auggie", "projects": [], + "permissions": {"settings_source": "managed", "scope": "managed", + "settings_path": "/etc/augment/settings.json"}, + } + self.assertTrue(_augment_owned_by_user(tool_filtered, "/Users/alice")) + + def test_user_scope_perms_count_as_owned(self): + # A surviving NON-managed (user-scope) permissions block means this user + # owns user-scope policy here -> owned even without owns_install. + tool_filtered = { + "name": "Auggie CLI", "_config_path": "/Users/alice/.augment", + "install_path": "/Users/alice/.local/bin/auggie", "projects": [], + "permissions": {"settings_source": "user", "scope": "user", + "settings_path": "/Users/bob/.augment/settings.json"}, + } + self.assertTrue(_augment_owned_by_user(tool_filtered, "/Users/bob")) + + def test_managed_only_full_flow_b_dropped_a_keeps_managed(self): + """FIX F end-to-end: managed /etc/augment is attached to alice's canonical + row. Under a root scan, B (no install) iterates the row -> the gate drops B + (managed alone doesn't manufacture a row); A keeps the managed (effective) + permissions.""" + utils_mod._SENTRY_DSN = "" + d = AIToolsDetector(os_name="Darwin") + d._augment_mcp_extractor = MagicMock() + d._augment_mcp_extractor.extract_mcp_config.return_value = None + d._augment_rules_extractor = MagicMock() + d._augment_rules_extractor.extract_all_augment_rules.return_value = [] + d._augment_skills_extractor = MagicMock() + d._augment_skills_extractor.extract_all_skills.return_value = { + "user_skills": [], "project_skills": []} + # Only a MANAGED (org-wide) settings record exists — no user-scope record. + d._augment_settings_extractor = MagicMock() + d._augment_settings_extractor.extract_settings.return_value = [ + {"tool_name": "Augment Code", "scope": "managed", + "settings_path": "/etc/augment/settings.json", + "raw_settings": {"toolPermissions": []}, + "permissions": {"defaultMode": None, "allow": ["read"], "deny": [], + "ask": [], "additionalDirectories": []}}, + ] + + cli = {"name": "Auggie CLI", "version": "0.30.0", + "install_path": "/Users/alice/.local/bin/auggie", + "_config_path": "/Users/alice/.augment"} + d._set_canonical_augment_surface([cli]) + processed = d.process_single_tool(cli) + + # Managed (effective) perms ARE attached to the canonical (alice) row. + self.assertIn("permissions", processed) + self.assertEqual(processed["permissions"]["settings_source"], "managed") + + # Alice (owner) keeps the row: managed perms survive filtering. + alice_view = d.filter_tool_projects_by_user(processed, Path("/Users/alice")) + self.assertTrue(_augment_owned_by_user(alice_view, "/Users/alice")) + self.assertIn("permissions", alice_view) + self.assertEqual(alice_view["permissions"]["settings_source"], "managed") + + # Bob (no install, no per-user data) is DROPPED: managed alone does not + # manufacture a row for a non-owner. + bob_view = d.filter_tool_projects_by_user(processed, Path("/Users/bob")) + self.assertFalse(_augment_owned_by_user(bob_view, "/Users/bob")) + + +class TestAugmentUserSkillsNoCrossUserLeak(unittest.TestCase): + """FIX 1: under a root all-users scan ``_get_augment_skills`` returns ALL + users' user-scope skills in one flat list. Each skill must be keyed under ITS + OWN config dir (``/.augment`` etc., derived from its ``file_path``) + so the per-user project filter scopes A's skill to A and B's skill to B — no + cross-user content leak — and ~/.augment skills coalesce with that row's + ~/.augment rules/MCP rather than a bare-home project. + """ + + def setUp(self): + utils_mod._SENTRY_DSN = "" + + def _detector_with_two_user_skills(self): + d = AIToolsDetector(os_name="Darwin") + d._augment_mcp_extractor = MagicMock() + d._augment_mcp_extractor.extract_mcp_config.return_value = None + d._augment_rules_extractor = MagicMock() + d._augment_rules_extractor.extract_all_augment_rules.return_value = [] + d._augment_settings_extractor = MagicMock() + d._augment_settings_extractor.extract_settings.return_value = [] + # A root all-users scan: BOTH users' user-scope skills in one flat list, + # each carrying its own home in file_path. + d._augment_skills_extractor = MagicMock() + d._augment_skills_extractor.extract_all_skills.return_value = { + "user_skills": [ + {"skill_name": "a", "type": "skill", "scope": "user", + "file_path": "/Users/alice/.augment/skills/a/SKILL.md"}, + {"skill_name": "b", "type": "skill", "scope": "user", + "file_path": "/Users/bob/.augment/skills/b/SKILL.md"}, + ], + "project_skills": [], + } + return d + + def test_each_users_skills_scoped_to_their_own_home(self): + d = self._detector_with_two_user_skills() + # Canonical row is alice's Auggie CLI (the surface that carries the + # shared config). Its skills are keyed by each skill's OWNER config dir. + cli = {"name": "Auggie CLI", "version": "0.30.0", + "install_path": "/Users/alice/.local/bin/auggie", + "_config_path": "/Users/alice/.augment"} + d._set_canonical_augment_surface([cli]) + processed = d.process_single_tool(cli) + + # Skills live under each owner's ~/.augment (config dir), not all under + # alice's install_key and not under a bare home. + by_path = {p["path"]: p for p in processed["projects"]} + self.assertIn("/Users/alice/.augment", by_path) + self.assertIn("/Users/bob/.augment", by_path) + + # Per-user filtering scopes each user's skills to their own home. + alice_view = d.filter_tool_projects_by_user(processed, Path("/Users/alice")) + bob_view = d.filter_tool_projects_by_user(processed, Path("/Users/bob")) + + alice_names = {s["skill_name"] for p in alice_view["projects"] for s in p["skills"]} + bob_names = {s["skill_name"] for p in bob_view["projects"] for s in p["skills"]} + + # Alice's row carries ONLY alice's skill; bob's ONLY bob's. No leak. + self.assertEqual(alice_names, {"a"}) + self.assertEqual(bob_names, {"b"}) + + +class TestAugmentSkillProjectRoot(unittest.TestCase): + """``_augment_skill_project_root`` returns the CONFIG DIR a user skill lives in + (so ~/.augment skills coalesce with ~/.augment rules/MCP), across .augment / + .claude / .agents, and stays owner-scoped. None when unparseable.""" + + def _root(self, file_path): + return AIToolsDetector._augment_skill_project_root({"file_path": file_path}) + + def test_augment_marker(self): + self.assertEqual(self._root("/Users/x/.augment/skills/a/SKILL.md"), "/Users/x/.augment") + + def test_claude_marker(self): + self.assertEqual(self._root("/Users/x/.claude/skills/a/SKILL.md"), "/Users/x/.claude") + + def test_agents_marker(self): + self.assertEqual(self._root("/Users/x/.agents/skills/a/SKILL.md"), "/Users/x/.agents") + + def test_windows_separator(self): + self.assertEqual(self._root(r"C:\Users\x\.augment\skills\a\SKILL.md"), r"C:\Users\x\.augment") + + def test_missing_or_unparseable_returns_none(self): + self.assertIsNone(self._root("")) + self.assertIsNone(self._root("/Users/x/random/file.md")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_augment_rules_extraction.py b/tests/test_augment_rules_extraction.py new file mode 100644 index 0000000..d660384 --- /dev/null +++ b/tests/test_augment_rules_extraction.py @@ -0,0 +1,245 @@ +""" +Integration tests for Augment Code rules/guidelines extraction (macOS). + +Exercises the outermost surface (``extract_all_augment_rules()``): + + - User: ``~/.augment/user-guidelines.md`` + ``~/.augment/rules/*.{md,mdx}`` + grouped under ``~/.augment`` (scope "user"). + - Project: ``.augment-guidelines`` + ``.augment/rules/*.{md,mdx}``. + - Revised D3: ``AGENTS.md`` AND ``CLAUDE.md`` ARE collected (hierarchically). + - ``.mdx`` regression + the no-frontmatter contract (rule dicts carry only the + backend's allowlisted fields). + - Sad-paths: symlink loop, depth bound, permission error. + +The temp dir lives under /var, which the real ``should_skip_system_path`` skips, +so the project walk neutralises it (mirrors the rules suite convention). +""" + +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import scripts.coding_discovery_tools.utils as utils_mod +from scripts.coding_discovery_tools.macos.augment.augment_rules_extractor import ( + MacOSAugmentRulesExtractor, +) + +_RULES_MOD = "scripts.coding_discovery_tools.macos.augment.augment_rules_extractor" + +# The backend's allowed rule-dict fields — every emitted rule must be a subset. +_ALLOWED_RULE_FIELDS = { + "file_path", "file_name", "content", "size", + "last_modified", "truncated", "scope", "project_path", +} + + +def _all_rules(projects): + rules = [] + for p in projects: + rules.extend(p.get("rules", [])) + return rules + + +def _names(projects): + return {r["file_name"] for r in _all_rules(projects)} + + +class _AugmentRulesHarness(unittest.TestCase): + def setUp(self): + utils_mod._SENTRY_DSN = "" + self.tmp_dir = tempfile.mkdtemp() + self.user_home = Path(self.tmp_dir) / "user" + self.augment_dir = self.user_home / ".augment" + self.augment_dir.mkdir(parents=True) + self.extractor = MacOSAugmentRulesExtractor() + + def tearDown(self): + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def _extract_user_only(self): + """Run only the user-scope extraction (no project walk).""" + with patch.object(self.extractor, "_scan_all_user_homes", + side_effect=lambda fn: fn(self.user_home)), \ + patch.object(self.extractor, "_iter_top_level_dirs", return_value=[]): + return self.extractor.extract_all_augment_rules() + + def _extract_with_project_root(self, repo: Path): + """Run extraction with the project walk pinned to ``repo`` (no user scan). + + ``_filesystem_root`` is pinned to the temp ancestor so the walk's + ``relative_to(root)`` depth check works on Windows too (a real path like + ``C:\\...\\repo`` is not relative to the macOS class's default ``/``). + """ + with patch.object(self.extractor, "_scan_all_user_homes", + side_effect=lambda fn: None), \ + patch.object(self.extractor, "_filesystem_root", + return_value=Path(self.tmp_dir)), \ + patch.object(self.extractor, "_iter_top_level_dirs", return_value=[repo]), \ + patch(f"{_RULES_MOD}.should_skip_system_path", return_value=False): + return self.extractor.extract_all_augment_rules() + + +class TestAugmentUserRules(_AugmentRulesHarness): + def test_user_guidelines_and_rules_grouped_under_augment_dir(self): + (self.augment_dir / "user-guidelines.md").write_text("be nice", encoding="utf-8") + rules_dir = self.augment_dir / "rules" + rules_dir.mkdir() + (rules_dir / "style.md").write_text("two spaces", encoding="utf-8") + (rules_dir / "security.mdx").write_text("no secrets", encoding="utf-8") + + projects = self._extract_user_only() + # Everything coalesces under the ~/.augment dir as a single project_root. + roots = {p["project_root"] for p in projects} + self.assertEqual(roots, {str(self.augment_dir)}) + self.assertEqual(_names(projects), {"user-guidelines.md", "style.md", "security.mdx"}) + for r in _all_rules(projects): + self.assertEqual(r["scope"], "user") + + def test_user_rules_mdx_regression(self): + rules_dir = self.augment_dir / "rules" + rules_dir.mkdir() + (rules_dir / "only.mdx").write_text("mdx rule", encoding="utf-8") + projects = self._extract_user_only() + self.assertIn("only.mdx", _names(projects)) + + +class TestAugmentProjectRules(_AugmentRulesHarness): + def _make_repo(self) -> Path: + repo = self.user_home / "repo" + repo.mkdir(parents=True) + return repo + + def test_augment_guidelines_and_rules_tree(self): + repo = self._make_repo() + (repo / ".augment-guidelines").write_text("repo rules", encoding="utf-8") + rules_dir = repo / ".augment" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "a.md").write_text("a", encoding="utf-8") + (rules_dir / "b.mdx").write_text("b", encoding="utf-8") + + projects = self._extract_with_project_root(repo) + self.assertIn(".augment-guidelines", _names(projects)) + self.assertIn("a.md", _names(projects)) + self.assertIn("b.mdx", _names(projects)) + for r in _all_rules(projects): + self.assertEqual(r["scope"], "project") + + def test_agents_md_and_claude_md_are_collected(self): + """Revised D3: Augment discovers AGENTS.md AND CLAUDE.md — both collected.""" + repo = self._make_repo() + (repo / "AGENTS.md").write_text("agents", encoding="utf-8") + (repo / "CLAUDE.md").write_text("claude", encoding="utf-8") + + projects = self._extract_with_project_root(repo) + names = _names(projects) + self.assertIn("AGENTS.md", names) + self.assertIn("CLAUDE.md", names) + + def test_agents_md_collected_hierarchically_in_subdir(self): + """AGENTS.md/CLAUDE.md are discovered at any depth (not just repo root).""" + repo = self._make_repo() + (repo / "AGENTS.md").write_text("root agents", encoding="utf-8") + sub = repo / "pkg" / "nested" + sub.mkdir(parents=True) + (sub / "CLAUDE.md").write_text("nested claude", encoding="utf-8") + + projects = self._extract_with_project_root(repo) + names = _names(projects) + self.assertIn("AGENTS.md", names) + self.assertIn("CLAUDE.md", names) + + def test_no_frontmatter_contract(self): + """Every emitted rule dict must be a subset of the backend's allowlist — + no frontmatter keys leak into the dict (they stay inside ``content``).""" + repo = self._make_repo() + rules_dir = repo / ".augment" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "fm.md").write_text( + "---\napplyTo: '**/*.py'\n---\nUse type hints", encoding="utf-8" + ) + projects = self._extract_with_project_root(repo) + rules = _all_rules(projects) + self.assertTrue(rules) + for r in rules: + self.assertTrue( + set(r.keys()).issubset(_ALLOWED_RULE_FIELDS), + f"rule dict has non-allowlisted keys: {set(r.keys()) - _ALLOWED_RULE_FIELDS}", + ) + # The frontmatter is preserved verbatim inside content. + fm_rule = next(r for r in rules if r["file_name"] == "fm.md") + self.assertIn("applyTo", fm_rule["content"]) + + +class TestAugmentRulesNoUserProjectDuplication(_AugmentRulesHarness): + """FIX 2: when the project walk descends into a user-home ``~/.augment`` it + must NOT re-collect ``~/.augment/rules/**`` as scope "project" (which would + duplicate the user-scope rule under a different project_root, defeating the + per-project dedup). The user-augment-dir guard skips it. + """ + + def test_user_rule_not_duplicated_as_project(self): + # The SAME home is seen by both the user scan and the project walk. + rules_dir = self.augment_dir / "rules" + rules_dir.mkdir() + (rules_dir / "style.md").write_text("two spaces", encoding="utf-8") + + with patch.object(self.extractor, "_scan_all_user_homes", + side_effect=lambda fn: fn(self.user_home)), \ + patch.object(self.extractor, "_filesystem_root", + return_value=Path(self.tmp_dir)), \ + patch.object(self.extractor, "_iter_top_level_dirs", + return_value=[self.user_home]), \ + patch(f"{_RULES_MOD}.should_skip_system_path", return_value=False): + projects = self.extractor.extract_all_augment_rules() + + style_rules = [r for r in _all_rules(projects) if r["file_name"] == "style.md"] + # Exactly ONE record, and it is the user-scope one (not a project dup). + self.assertEqual(len(style_rules), 1) + self.assertEqual(style_rules[0]["scope"], "user") + roots = {p["project_root"] for p in projects if p.get("rules")} + self.assertEqual(roots, {str(self.augment_dir)}) + + +class TestAugmentRulesSadPaths(_AugmentRulesHarness): + def test_symlink_loop_does_not_hang(self): + repo = self.user_home / "repo" + rules_dir = repo / ".augment" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "x.md").write_text("x", encoding="utf-8") + # A self-referential symlink inside the rules tree must be skipped. + try: + os.symlink(str(rules_dir), str(rules_dir / "loop")) + except (OSError, NotImplementedError): + self.skipTest("symlinks not supported") + projects = self._extract_with_project_root(repo) + self.assertIn("x.md", _names(projects)) + + def test_depth_bound_respected(self): + repo = self.user_home / "repo" + repo.mkdir(parents=True) + # A rule far beyond MAX_SEARCH_DEPTH must not crash the walk. + deep = repo + for i in range(15): + deep = deep / f"d{i}" + deep.mkdir(parents=True) + (deep / "AGENTS.md").write_text("deep", encoding="utf-8") + (repo / "AGENTS.md").write_text("shallow", encoding="utf-8") + # Should not raise; the shallow file is collected. + projects = self._extract_with_project_root(repo) + self.assertIn("AGENTS.md", _names(projects)) + + def test_permission_error_never_raises(self): + repo = self.user_home / "repo" + repo.mkdir(parents=True) + (repo / "AGENTS.md").write_text("ok", encoding="utf-8") + with patch.object(Path, "iterdir", side_effect=PermissionError("denied")): + # Must return cleanly (the walk swallows the error). + projects = self._extract_with_project_root(repo) + self.assertIsInstance(projects, list) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_augment_settings_extraction.py b/tests/test_augment_settings_extraction.py new file mode 100644 index 0000000..13d3d59 --- /dev/null +++ b/tests/test_augment_settings_extraction.py @@ -0,0 +1,228 @@ +""" +Integration tests for Augment Code settings/permissions extraction (macOS). + +Exercises the outermost surface (``extract_settings()``) plus the D6 regression: +``hooks`` must survive end-to-end through ``transform_settings_to_backend_format`` +inside ``raw_settings`` (the transformer does NOT lift hooks). Also covers +``toolPermissions`` -> allow/deny/ask mapping, ``shellInputRegex`` retention, +scope precedence, and sad-paths (JSONC, invalid JSON, oversize, missing). + +The extractor collects USER + MANAGED scopes only — there is no project/local +filesystem walk (those scopes cannot be surfaced in the tool-level permissions +blob the backend supports, so the expensive whole-disk walk was removed). +""" + +import json +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import scripts.coding_discovery_tools.utils as utils_mod +from scripts.coding_discovery_tools.macos.augment.augment_settings_extractor import ( + MacOSAugmentSettingsExtractor, +) +from scripts.coding_discovery_tools.settings_transformers import ( + transform_settings_to_backend_format, +) + +_SETTINGS_MOD = "scripts.coding_discovery_tools.macos.augment.augment_settings_extractor" + + +def _tool_perm(tool, ptype, regex=None, event="pre"): + entry = {"toolName": tool, "eventType": event, "permission": {"type": ptype}} + if regex is not None: + entry["shellInputRegex"] = regex + return entry + + +class _AugmentSettingsHarness(unittest.TestCase): + """Pins the settings extractor to a single hermetic ~/.augment, no project walk.""" + + def setUp(self): + utils_mod._SENTRY_DSN = "" + self.tmp_dir = tempfile.mkdtemp() + self.user_home = Path(self.tmp_dir) / "user" + self.augment_dir = self.user_home / ".augment" + self.augment_dir.mkdir(parents=True) + self.extractor = MacOSAugmentSettingsExtractor() + # Scope to this user, no managed file. The extractor does no project + # filesystem walk (user + managed scopes only). + self._patchers = [ + patch.object(self.extractor, "_user_settings_scan", + side_effect=lambda fn: fn(self.user_home)), + patch.object(self.extractor, "_managed_settings_path", + return_value=Path(self.tmp_dir) / "nope" / "settings.json"), + ] + for p in self._patchers: + p.start() + + def tearDown(self): + for p in self._patchers: + p.stop() + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def _write_user_settings(self, data, filename="settings.json"): + path = self.augment_dir / filename + path.write_text(json.dumps(data) if isinstance(data, (dict, list)) else data, + encoding="utf-8") + return path + + +class TestAugmentToolPermissions(_AugmentSettingsHarness): + def test_tool_permissions_mapped_to_allow_deny_ask(self): + self._write_user_settings({ + "toolPermissions": [ + _tool_perm("read-file", "allow"), + _tool_perm("delete-file", "deny"), + _tool_perm("run-shell", "ask-user"), + ], + }) + records = self.extractor.extract_settings() + self.assertEqual(len(records), 1) + perms = records[0]["permissions"] + self.assertIn("read-file", perms["allow"]) + self.assertIn("delete-file", perms["deny"]) + self.assertIn("run-shell", perms["ask"]) + + def test_shell_input_regex_appended_to_tool_name(self): + self._write_user_settings({ + "toolPermissions": [ + _tool_perm("run-shell", "allow", regex="^git status"), + ], + }) + records = self.extractor.extract_settings() + self.assertIn("run-shell(^git status)", records[0]["permissions"]["allow"]) + + def test_unknown_permission_type_ignored(self): + self._write_user_settings({ + "toolPermissions": [ + _tool_perm("x", "allow"), + _tool_perm("y", "weird-type"), + {"toolName": "z"}, # missing permission + ], + }) + perms = self.extractor.extract_settings()[0]["permissions"] + self.assertEqual(perms["allow"], ["x"]) + self.assertEqual(perms["deny"], []) + self.assertEqual(perms["ask"], []) + + +class TestAugmentHooksRegressionD6(_AugmentSettingsHarness): + def test_hooks_preserved_in_raw_settings(self): + self._write_user_settings({ + "toolPermissions": [_tool_perm("read", "allow")], + "hooks": {"PreToolUse": [{"command": "echo audit"}]}, + }) + records = self.extractor.extract_settings() + self.assertIn("hooks", records[0]["raw_settings"]) + + def test_hooks_survive_transform_to_backend_format(self): + """D6 end-to-end: the transformer does NOT lift hooks, so they MUST ride + inside raw_settings and survive into the backend payload.""" + self._write_user_settings({ + "toolPermissions": [_tool_perm("read", "allow")], + "hooks": {"PreToolUse": [{"command": "echo audit"}]}, + }) + records = self.extractor.extract_settings() + backend = transform_settings_to_backend_format(records) + self.assertIsNotNone(backend) + self.assertEqual( + backend["raw_settings"]["hooks"], + {"PreToolUse": [{"command": "echo audit"}]}, + ) + # The permission was also lifted into allow_rules. + self.assertIn("read", backend.get("allow_rules", [])) + + +class TestAugmentScopePrecedence(_AugmentSettingsHarness): + def test_managed_beats_user(self): + self._write_user_settings({"toolPermissions": [_tool_perm("user-tool", "allow")]}) + managed_dir = Path(self.tmp_dir) / "etc" / "augment" + managed_dir.mkdir(parents=True) + managed_path = managed_dir / "settings.json" + managed_path.write_text(json.dumps({ + "toolPermissions": [_tool_perm("managed-tool", "deny")], + }), encoding="utf-8") + with patch.object(self.extractor, "_managed_settings_path", return_value=managed_path): + records = self.extractor.extract_settings() + backend = transform_settings_to_backend_format(records) + # Managed (precedence 4) wins over user (1). + self.assertEqual(backend["scope"], "managed") + self.assertIn("managed-tool", backend.get("deny_rules", [])) + + +class TestAugmentSettingsSadPaths(_AugmentSettingsHarness): + def test_jsonc_comments_and_trailing_commas_tolerated(self): + self._write_user_settings( + '{\n // a comment\n "toolPermissions": [\n' + ' {"toolName": "read", "eventType": "pre", "permission": {"type": "allow"}},\n' + ' ],\n}' + ) + records = self.extractor.extract_settings() + self.assertEqual(len(records), 1) + self.assertIn("read", records[0]["permissions"]["allow"]) + + def test_invalid_user_json_yields_empty(self): + # A broken user settings file is warned-and-skipped; there is no project + # walk to fall back on, so the result is empty. + self._write_user_settings("{ not json at all") + records = self.extractor.extract_settings() + self.assertEqual(records, []) + + def test_oversize_file_truncated_skipped(self): + with patch(f"{_SETTINGS_MOD}._MAX_SETTINGS_BYTES", 10): + self._write_user_settings({"toolPermissions": [_tool_perm("read", "allow")]}) + records = self.extractor.extract_settings() + self.assertEqual(records, []) + + def test_missing_settings_yields_empty(self): + records = self.extractor.extract_settings() + self.assertEqual(records, []) + + +class TestAugmentNoProjectWalk(_AugmentSettingsHarness): + """FIX 3: project/local settings are no longer collected (no whole-disk walk). + + Planting a ``/.augment/settings.json`` (and ``settings.local.json``) + anywhere on disk must NOT surface a project/local record — only user + + managed scopes are extracted. + """ + + def test_project_settings_not_collected(self): + # A planted project ``.augment/settings.json`` (+ local) must NOT surface: + # the extractor does no filesystem walk, so no project/local record exists + # even though the file is on disk and reachable. + self._write_user_settings({"toolPermissions": [_tool_perm("u", "allow")]}) + project_dir = self.user_home / "repo" / ".augment" + project_dir.mkdir(parents=True) + (project_dir / "settings.json").write_text(json.dumps({ + "toolPermissions": [_tool_perm("project-tool", "allow")], + }), encoding="utf-8") + (project_dir / "settings.local.json").write_text(json.dumps({ + "toolPermissions": [_tool_perm("local-tool", "allow")], + }), encoding="utf-8") + records = self.extractor.extract_settings() + # Only the user-scope record; no project/local records. + scopes = sorted(r["scope"] for r in records) + self.assertEqual(scopes, ["user"]) + + def test_managed_settings_surface(self): + """FIX 3: managed /etc/augment/settings.json permissions DO surface + (they were extracted-then-dropped by the old consumer ``own`` filter).""" + self._write_user_settings({"toolPermissions": [_tool_perm("u", "allow")]}) + managed_dir = Path(self.tmp_dir) / "etc" / "augment" + managed_dir.mkdir(parents=True) + managed_path = managed_dir / "settings.json" + managed_path.write_text(json.dumps({ + "toolPermissions": [_tool_perm("managed-tool", "deny")], + }), encoding="utf-8") + with patch.object(self.extractor, "_managed_settings_path", return_value=managed_path): + records = self.extractor.extract_settings() + scopes = sorted(r["scope"] for r in records) + self.assertEqual(scopes, ["managed", "user"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_augment_skills_extraction.py b/tests/test_augment_skills_extraction.py new file mode 100644 index 0000000..d4f6f10 --- /dev/null +++ b/tests/test_augment_skills_extraction.py @@ -0,0 +1,218 @@ +""" +Integration tests for Augment Code skills/commands extraction (macOS). + +Exercises the outermost surface (``extract_all_skills()``): + + - User skills (nested ``~/.augment/skills//SKILL.md``) -> type "skill", + source "standalone". + - User commands (flat ``~/.augment/commands/*.md``) -> type "command". + - Project commands (flat ``/.augment/commands/*.md``) -> type "command". + - Project skills (nested) grouped by project root. + - Dedup, empty dir -> nothing, missing SKILL.md skipped. + +The temp dir lives under /var (skipped by the real ``should_skip_system_path``), +so the project walk neutralises the skip predicate via the extractor's seam. +""" + +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import scripts.coding_discovery_tools.utils as utils_mod +from scripts.coding_discovery_tools.macos.augment.augment_skills_extractor import ( + MacOSAugmentSkillsExtractor, +) + + +class _AugmentSkillsHarness(unittest.TestCase): + def setUp(self): + utils_mod._SENTRY_DSN = "" + self.tmp_dir = tempfile.mkdtemp() + self.user_home = Path(self.tmp_dir) / "user" + self.augment_dir = self.user_home / ".augment" + self.augment_dir.mkdir(parents=True) + self.extractor = MacOSAugmentSkillsExtractor() + + def tearDown(self): + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def _extract_user_only(self): + with patch.object(self.extractor, "_scan_all_user_homes", + side_effect=lambda fn: fn(self.user_home)), \ + patch.object(self.extractor, "_iter_top_level_dirs", return_value=[]): + return self.extractor.extract_all_skills() + + def _extract_project_only(self, repo: Path): + # ``_filesystem_root`` is pinned to the temp ancestor so the walk's + # ``relative_to(root)`` depth check works on Windows too (a real path like + # ``C:\...\repo`` is not relative to the macOS class's default ``/``). + with patch.object(self.extractor, "_scan_all_user_homes", + side_effect=lambda fn: None), \ + patch.object(self.extractor, "_filesystem_root", + return_value=Path(self.tmp_dir)), \ + patch.object(self.extractor, "_iter_top_level_dirs", return_value=[repo]), \ + patch.object(self.extractor, "_should_skip_walk_item", return_value=False), \ + patch.object(self.extractor, "_is_user_level_skill_dir", return_value=False): + return self.extractor.extract_all_skills() + + def _write_nested_skill(self, base: Path, name: str, body="do the thing"): + skill_dir = base / "skills" / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text(body, encoding="utf-8") + + def _write_command(self, base: Path, name: str, body="run it"): + commands_dir = base / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / f"{name}.md").write_text(body, encoding="utf-8") + + +class TestAugmentUserSkills(_AugmentSkillsHarness): + def test_user_nested_skill_type_and_source(self): + self._write_nested_skill(self.augment_dir, "deploy") + result = self._extract_user_only() + user = result["user_skills"] + self.assertEqual(len(user), 1) + self.assertEqual(user[0]["type"], "skill") + self.assertEqual(user[0]["source"], "standalone") + self.assertEqual(user[0]["skill_name"], "deploy") + self.assertEqual(user[0]["scope"], "user") + + def test_user_flat_command_type(self): + self._write_command(self.augment_dir, "review") + result = self._extract_user_only() + user = result["user_skills"] + self.assertEqual(len(user), 1) + self.assertEqual(user[0]["type"], "command") + self.assertEqual(user[0]["skill_name"], "review") + + def test_user_skills_from_claude_and_agents_dirs(self): + # Augment also loads home-scope skills from ~/.claude and ~/.agents + # (docs.augmentcode.com/cli/skills), not just ~/.augment. + self._write_nested_skill(self.user_home / ".claude", "from-claude") + self._write_nested_skill(self.user_home / ".agents", "from-agents") + result = self._extract_user_only() + names = {s["skill_name"] for s in result["user_skills"]} + self.assertIn("from-claude", names) + self.assertIn("from-agents", names) + + def test_user_command_from_claude_dir(self): + # Auggie honors ~/.claude/commands for Claude compatibility. + self._write_command(self.user_home / ".claude", "claude-cmd") + result = self._extract_user_only() + cmds = {s["skill_name"] for s in result["user_skills"] if s["type"] == "command"} + self.assertIn("claude-cmd", cmds) + + def test_missing_skill_md_skipped(self): + # A skill subdir with NO SKILL.md is not collected. + (self.augment_dir / "skills" / "empty").mkdir(parents=True) + result = self._extract_user_only() + self.assertEqual(result["user_skills"], []) + + def test_empty_dirs_yield_nothing(self): + (self.augment_dir / "skills").mkdir() + (self.augment_dir / "commands").mkdir() + result = self._extract_user_only() + self.assertEqual(result["user_skills"], []) + self.assertEqual(result["project_skills"], []) + + +class TestAugmentProjectSkills(_AugmentSkillsHarness): + def test_project_command_flat(self): + repo = self.user_home / "repo" + augment = repo / ".augment" + self._write_command(augment, "bug-fix") + result = self._extract_project_only(repo) + all_skills = [s for p in result["project_skills"] for s in p["skills"]] + self.assertEqual(len(all_skills), 1) + self.assertEqual(all_skills[0]["type"], "command") + self.assertEqual(all_skills[0]["skill_name"], "bug-fix") + + def test_project_nested_skill_grouped_by_root(self): + repo = self.user_home / "repo" + augment = repo / ".augment" + self._write_nested_skill(augment, "perf") + result = self._extract_project_only(repo) + self.assertEqual(len(result["project_skills"]), 1) + self.assertEqual(result["project_skills"][0]["project_root"], str(repo)) + self.assertEqual(result["project_skills"][0]["skills"][0]["skill_name"], "perf") + + def test_project_skill_from_claude_dir(self): + # Project-scope .claude/skills is collected too (grouped under the repo). + repo = self.user_home / "repo" + self._write_nested_skill(repo / ".claude", "claude-proj-skill") + result = self._extract_project_only(repo) + all_skills = [s for p in result["project_skills"] for s in p["skills"]] + names = {s["skill_name"] for s in all_skills} + self.assertIn("claude-proj-skill", names) + + def test_project_skills_deduped(self): + # The dedup happens downstream in process_single_tool; here verify the + # extractor emits one entry per distinct file (no accidental duplication). + repo = self.user_home / "repo" + augment = repo / ".augment" + self._write_nested_skill(augment, "alpha") + self._write_command(augment, "beta") + result = self._extract_project_only(repo) + all_skills = [s for p in result["project_skills"] for s in p["skills"]] + names = sorted(s["skill_name"] for s in all_skills) + self.assertEqual(names, ["alpha", "beta"]) + + +class TestAugmentSkillsSadPaths(_AugmentSkillsHarness): + def test_symlinked_augment_dir_not_followed(self): + """FIX A: a symlinked ``.augment`` in the project walk must be skipped + BEFORE the parent-dir handling, so its skills are never collected (same + class of bug already fixed in the rules walk; loop/perf risk).""" + # A real .augment skills tree lives OUTSIDE the scanned repo. + target_augment = self.user_home / "external" / ".augment" + self._write_nested_skill(target_augment, "leaked") + + # The scanned repo exposes .augment ONLY via a symlink to the target. + repo = self.user_home / "repo" + repo.mkdir(parents=True) + try: + os.symlink(str(target_augment), str(repo / ".augment")) + except (OSError, NotImplementedError): + self.skipTest("symlinks not supported") + + result = self._extract_project_only(repo) + all_names = {s["skill_name"] for p in result["project_skills"] for s in p["skills"]} + # The symlinked .augment must NOT be followed -> no "leaked" skill. + self.assertNotIn("leaked", all_names) + self.assertEqual(result["project_skills"], []) + + def test_real_augment_dir_still_collected(self): + """No behaviour change for a NON-symlink ``.augment``: still collected.""" + repo = self.user_home / "repo" + augment = repo / ".augment" + self._write_nested_skill(augment, "kept") + result = self._extract_project_only(repo) + all_names = {s["skill_name"] for p in result["project_skills"] for s in p["skills"]} + self.assertIn("kept", all_names) + + def test_symlinked_skills_subdir_not_traversed(self): + """A symlinked ``.augment/skills`` subdir must NOT be traversed — mirrors + the parent ``.augment`` symlink guard. Under a root MDM scan a user could + point ``.augment/skills`` at an arbitrary dir; the scanner must not follow + it. (The parent ``.augment`` here is a real dir; only ``skills`` is the + symlink.)""" + external = Path(self.tmp_dir) / "external" / "skills" + (external / "leaked").mkdir(parents=True) + (external / "leaked" / "SKILL.md").write_text("leaked") + repo = self.user_home / "repo" + (repo / ".augment").mkdir(parents=True) + try: + os.symlink(str(external), str(repo / ".augment" / "skills")) + except (OSError, NotImplementedError): + self.skipTest("symlinks not supported") + result = self._extract_project_only(repo) + names = {s["skill_name"] for p in result["project_skills"] for s in p["skills"]} + self.assertNotIn("leaked", names) + self.assertEqual(result["project_skills"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_augment_skills_extraction_linux.py b/tests/test_augment_skills_extraction_linux.py new file mode 100644 index 0000000..6890814 --- /dev/null +++ b/tests/test_augment_skills_extraction_linux.py @@ -0,0 +1,80 @@ +""" +Linux-variant integration tests for Augment Code skills/commands extraction. + +The Linux extractor is a thin subclass of the macOS one overriding only OS seams +(file-metadata read, walk-skip predicate, all-users scan, filesystem root, +top-level enumeration, and the ``/home``+``/root`` user-level-dir check). These +tests exercise ``extract_all_skills()`` through the Linux subclass with the seams +pinned to a hermetic temp tree. +""" + +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import scripts.coding_discovery_tools.utils as utils_mod +from scripts.coding_discovery_tools.linux.augment.augment_skills_extractor import ( + LinuxAugmentSkillsExtractor, +) + + +class TestLinuxAugmentSkills(unittest.TestCase): + def setUp(self): + utils_mod._SENTRY_DSN = "" + self.tmp_dir = tempfile.mkdtemp() + self.user_home = Path(self.tmp_dir) / "home" / "user" + self.augment_dir = self.user_home / ".augment" + self.augment_dir.mkdir(parents=True) + self.extractor = LinuxAugmentSkillsExtractor() + + def tearDown(self): + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_user_skill_via_linux_subclass(self): + skill_dir = self.augment_dir / "skills" / "deploy" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("deploy it", encoding="utf-8") + with patch.object(self.extractor, "_scan_all_user_homes", + side_effect=lambda fn: fn(self.user_home)), \ + patch.object(self.extractor, "_iter_top_level_dirs", return_value=[]): + result = self.extractor.extract_all_skills() + self.assertEqual(len(result["user_skills"]), 1) + self.assertEqual(result["user_skills"][0]["type"], "skill") + self.assertEqual(result["user_skills"][0]["source"], "standalone") + + def test_project_command_via_linux_subclass(self): + repo = self.user_home / "repo" + commands_dir = repo / ".augment" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "review.md").write_text("review", encoding="utf-8") + with patch.object(self.extractor, "_scan_all_user_homes", + side_effect=lambda fn: None), \ + patch.object(self.extractor, "_filesystem_root", + return_value=Path(self.tmp_dir)), \ + patch.object(self.extractor, "_iter_top_level_dirs", return_value=[repo]), \ + patch.object(self.extractor, "_should_skip_walk_item", return_value=False), \ + patch.object(self.extractor, "_is_user_level_skill_dir", return_value=False): + result = self.extractor.extract_all_skills() + all_skills = [s for p in result["project_skills"] for s in p["skills"]] + self.assertEqual(len(all_skills), 1) + self.assertEqual(all_skills[0]["type"], "command") + self.assertEqual(all_skills[0]["skill_name"], "review") + + def test_user_level_skill_dir_recognized_for_home_user(self): + """The Linux user-level-dir check pins users-root to ``/home``.""" + type_dir = Path("/home/alice/.augment/skills") + self.assertTrue(self.extractor._is_user_level_skill_dir(type_dir)) + + def test_user_level_skill_dir_recognized_for_root_home(self): + type_dir = Path("/root/.augment/skills") + self.assertTrue(self.extractor._is_user_level_skill_dir(type_dir)) + + def test_project_skill_dir_not_user_level(self): + type_dir = Path("/home/alice/projects/repo/.augment/skills") + self.assertFalse(self.extractor._is_user_level_skill_dir(type_dir)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_discovery_flow.py b/tests/test_discovery_flow.py index 14595ee..8b59b03 100644 --- a/tests/test_discovery_flow.py +++ b/tests/test_discovery_flow.py @@ -91,6 +91,88 @@ def test_detect_and_report_flow(self): self.assertIn("projects", reported_tool) +class TestAugmentInDiscoveryFlow(unittest.TestCase): + """Augment Code is wired into the end-to-end discovery flow and is fail-safe.""" + + def setUp(self): + utils_mod._SENTRY_DSN = "" + + def test_augment_detector_registered(self): + from scripts.coding_discovery_tools.coding_tool_factory import ToolDetectorFactory + for os_name in ("Darwin", "Windows", "Linux"): + names = [d.tool_name for d in ToolDetectorFactory.create_all_tool_detectors(os_name)] + self.assertIn("Augment Code", names) + + def test_augment_surfaces_flow_through_generate_report(self): + """A detected Augment surface set flows through generate_report e2e: the + canonical CLI row carries config, the others are bare.""" + detector = AIToolsDetector(os_name="Darwin") + # Stub the shared extractors (no real disk walk). + detector._augment_mcp_extractor = Mock() + detector._augment_mcp_extractor.extract_mcp_config.return_value = { + "projects": [{"path": "/Users/x/.augment", "mcpServers": [{"name": "s"}], "scope": "user"}], + } + detector._augment_rules_extractor = Mock() + detector._augment_rules_extractor.extract_all_augment_rules.return_value = [] + detector._augment_skills_extractor = Mock() + detector._augment_skills_extractor.extract_all_skills.return_value = { + "user_skills": [], "project_skills": [], + } + detector._augment_settings_extractor = Mock() + detector._augment_settings_extractor.extract_settings.return_value = [] + + augment_tools = [ + {"name": "Auggie CLI", "version": "0.30.0", + "install_path": "/Users/x/.local/bin/auggie", "_config_path": "/Users/x/.augment"}, + {"name": "Augment (VS Code)", "version": "1.0", + "install_path": "/Users/x/.vscode/extensions", "_config_path": "/Users/x/.augment"}, + ] + with patch.object(detector, "detect_all_tools", return_value=augment_tools), \ + patch.object(detector, "get_device_id", return_value="DEV-1"): + report = detector.generate_report() + + by_name = {t["name"]: t for t in report["tools"]} + self.assertIn("Auggie CLI", by_name) + self.assertIn("Augment (VS Code)", by_name) + # Canonical CLI carries the shared MCP project; the VS Code row is bare. + self.assertTrue(by_name["Auggie CLI"]["projects"]) + self.assertEqual(by_name["Augment (VS Code)"]["projects"], []) + + def test_failing_augment_extractor_does_not_break_scan(self): + """A throwing Augment extractor degrades to an empty Augment row WITHOUT + breaking the scan or other tools' data (memoized accessors swallow + warn).""" + detector = AIToolsDetector(os_name="Darwin") + # Augment rules/MCP/skills/settings all explode. + detector._augment_mcp_extractor = Mock() + detector._augment_mcp_extractor.extract_mcp_config.side_effect = RuntimeError("boom") + detector._augment_rules_extractor = Mock() + detector._augment_rules_extractor.extract_all_augment_rules.side_effect = RuntimeError("boom") + detector._augment_skills_extractor = Mock() + detector._augment_skills_extractor.extract_all_skills.side_effect = RuntimeError("boom") + detector._augment_settings_extractor = Mock() + detector._augment_settings_extractor.extract_settings.side_effect = RuntimeError("boom") + + tools = [ + {"name": "Auggie CLI", "version": "0.30.0", + "install_path": "/Users/x/.local/bin/auggie", "_config_path": "/Users/x/.augment"}, + # An unrelated tool whose data must survive the Augment failure. + {"name": "OpenClaw", "version": "1.0", "install_path": "/opt/openclaw", + "is_installed": True}, + ] + with patch.object(detector, "detect_all_tools", return_value=tools), \ + patch.object(detector, "get_device_id", return_value="DEV-2"): + report = detector.generate_report() + + by_name = {t["name"]: t for t in report["tools"]} + # Scan completed: both rows present. + self.assertIn("Auggie CLI", by_name) + self.assertIn("OpenClaw", by_name) + # Augment degraded to an empty row (no crash). + self.assertEqual(by_name["Auggie CLI"]["projects"], []) + # The unrelated tool's row is intact. + self.assertTrue(by_name["OpenClaw"]["is_installed"]) + + class TestMainCLI(unittest.TestCase): """Integration tests that invoke main() via subprocess."""