From 633ba70eee8d0963afc68e4e76b34943df9ab917 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 27 Mar 2026 16:29:43 +0100 Subject: [PATCH 1/2] feat: report managed install changes --- CHANGELOG.md | 12 + README.md | 11 + .../.openspec.yaml | 2 + .../show-install-update-changes/design.md | 63 +++++ .../show-install-update-changes/proposal.md | 27 ++ .../specs/cli-commands/spec.md | 28 ++ .../specs/install-management/spec.md | 24 ++ .../show-install-update-changes/tasks.md | 21 ++ src/cache/cache.ts | 140 ++++++---- src/commands/install.test.ts | 109 ++++++++ src/commands/install.ts | 239 ++++++++++++------ src/install/copy.test.ts | 58 +++-- src/install/copy.ts | 33 ++- src/install/managed.test.ts | 129 ++++++++-- src/install/managed.ts | 33 ++- src/integration.test.ts | 167 +++++++++--- src/log/logger.test.ts | 14 +- 17 files changed, 887 insertions(+), 223 deletions(-) create mode 100644 openspec/changes/show-install-update-changes/.openspec.yaml create mode 100644 openspec/changes/show-install-update-changes/design.md create mode 100644 openspec/changes/show-install-update-changes/proposal.md create mode 100644 openspec/changes/show-install-update-changes/specs/cli-commands/spec.md create mode 100644 openspec/changes/show-install-update-changes/specs/install-management/spec.md create mode 100644 openspec/changes/show-install-update-changes/tasks.md create mode 100644 src/commands/install.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d73cdd..409acc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +### Features + +- **Verbose install update summaries** — `agentdeps install` now reports which managed skills and agents were added, updated, or removed when a dependency repository changes, making registry updates much easier to verify. + +### Notes + +- No new flags were added; unchanged installs still stay concise and report targets as up to date. +- Link installs detect updated managed items from cached repository changes, while copy installs report updates when synced content changes on disk. + + ## 0.5.0 ### Features diff --git a/README.md b/README.md index f3174f8..f77b8a0 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,17 @@ agentdeps install This clones/updates all dependency repos, discovers skills and agents, and installs them into each configured coding agent's directories. +When install changes managed items, the output now includes itemized change groups so you can see exactly what changed: + +```text +✓ Project (Pi): 1 skill added, 1 skill updated, 1 skill removed + skills added: new-skill + skills updated: my-skill + skills removed: another-skill +``` + +If nothing changed, install stays concise and reports the target as `up to date`. + ### 4. List dependencies ```bash diff --git a/openspec/changes/show-install-update-changes/.openspec.yaml b/openspec/changes/show-install-update-changes/.openspec.yaml new file mode 100644 index 0000000..a61e7c1 --- /dev/null +++ b/openspec/changes/show-install-update-changes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-27 diff --git a/openspec/changes/show-install-update-changes/design.md b/openspec/changes/show-install-update-changes/design.md new file mode 100644 index 0000000..67faeb6 --- /dev/null +++ b/openspec/changes/show-install-update-changes/design.md @@ -0,0 +1,63 @@ +## Context + +`agentdeps install` currently reports aggregate counts per scope by reading `SyncSummary.added` and `SyncSummary.removed` from `syncManagedDir()`. That keeps the output short, but it loses the concrete item names users care about after a repository update. It also does not model `updated` as a first-class outcome: link-mode replacements are counted as additions, and copy-mode syncs classify existing targets as unchanged even when files were overwritten or removed. + +The install flow already has a natural place to surface better feedback. `src/install/managed.ts` decides whether each managed item was created, replaced, synchronized, pruned, or left alone, and `src/commands/install.ts` formats the user-facing summary. Improving those two layers keeps the change localized and avoids altering dependency resolution or registry semantics. + +## Goals / Non-Goals + +**Goals:** +- Distinguish top-level managed items that were added, updated, removed, or unchanged during install +- Show itemized install output when changes occur so users can see which skills changed after a repository update +- Preserve concise `up to date` output when install makes no changes +- Cover both link and copy install methods with consistent change categories + +**Non-Goals:** +- Showing file-level diffs within a skill or agent +- Adding a new `--verbose` flag or other CLI surface area in this change +- Changing dependency resolution, repository caching behavior, or install destinations +- Reworking the overall install flow beyond the reporting data it already produces + +## Decisions + +### 1. Track `updated` as a first-class managed-item outcome + +`SyncSummary` will grow from `{ added, removed, unchanged }` to `{ added, updated, removed, unchanged }`. + +- In link mode, `ensureSymlink()` returning `created` maps to `added`, `replaced` maps to `updated`, and `unchanged` stays `unchanged`. +- In copy mode, an existing target that required filesystem mutations during sync maps to `updated`; an existing target with no mutations remains `unchanged`. + +**Rationale:** The managed install layer already knows whether each top-level item was created, replaced, changed in place, or left alone. Making that explicit gives the CLI an accurate source of truth for user-facing reporting. + +**Alternative considered:** Infer updates later from aggregate counts or cache metadata. Rejected because counts lose item identity and cache changes do not always translate directly into installed-item changes. + +### 2. Make smart copy sync report whether it mutated the target + +`smartSync()` will return a change indicator that tells callers whether any files or directories were added, overwritten, or removed while synchronizing an existing target. + +**Rationale:** `syncManagedDir()` cannot accurately distinguish `updated` from `unchanged` in copy mode unless the copy layer reports whether it actually mutated the destination. + +**Alternative considered:** Snapshot and diff destination trees before and after sync. Rejected as more complex and less efficient than returning change information from the code that already performs the mutations. + +### 3. Report final installed item names at the managed-directory level + +Install output will be driven by the final managed sync results per scope/agent target, not by per-repository git diffs. + +**Rationale:** The install command merges resolved items across dependencies and deduplicates shared target directories before syncing. Reporting the final managed-item names is what users need to confirm what actually changed in their installed environment, and it avoids threading repository provenance through the full pipeline. + +**Alternative considered:** Compute per-repository diffs from cached git state and print those. Rejected because it adds cache-layer coupling and can diverge from what was ultimately installed after filtering and deduplication. + +### 4. Keep no-op installs concise and expand only changed categories + +`src/commands/install.ts` will continue to emit a compact `up to date` summary when nothing changed. When a scope/agent target has changes, the output will include the change counts plus indented item lists for non-empty `added`, `updated`, and `removed` groups. + +**Rationale:** This matches the user's request for more verbosity when installs change while preserving the current low-noise experience for repeat installs. + +**Alternative considered:** Always print full added/updated/removed sections or add a dedicated verbosity flag. Rejected because the current request is about making changed installs more informative, not making every install noisier or expanding the CLI API. + +## Risks / Trade-offs + +- **[Risk] Large updates could produce long output** → Mitigation: only print non-empty change groups and keep unchanged items summarized as `up to date`. +- **[Risk] Copy-mode update classification could drift from real filesystem mutations** → Mitigation: derive `updated` directly from `smartSync()`'s actual write/remove operations instead of a second-pass guess. +- **[Trade-off] Output is grouped by final managed target, not by source repository** → Acceptable because users asked to know which installed skills changed, and the managed target is the authoritative final state. +- **[Trade-off] This adds more reporting data to tests** → Acceptable because the richer summaries should be locked down with unit and integration coverage. diff --git a/openspec/changes/show-install-update-changes/proposal.md b/openspec/changes/show-install-update-changes/proposal.md new file mode 100644 index 0000000..4b3cc48 --- /dev/null +++ b/openspec/changes/show-install-update-changes/proposal.md @@ -0,0 +1,27 @@ +## Why + +`agentdeps install` currently reports only aggregate added/removed counts per scope. When an existing cached repository is updated, users cannot see which managed skills changed, which makes it hard to verify a registry update or understand why a project's installed capabilities now differ. + +## What Changes + +- Expand `agentdeps install` reporting so updated dependency repositories produce itemized change output instead of only aggregate counts. +- Distinguish managed items that were added, updated, and removed during install, with clear skill-level reporting for repositories that changed. +- Keep the install output concise when nothing changed, while surfacing detailed change lists only when they add value. + +## Capabilities + +### New Capabilities + +_(none — this extends existing capabilities)_ + +### Modified Capabilities + +- `cli-commands`: The `install` command reports which managed items changed when a dependency repository update results in added, updated, or removed installs. +- `install-management`: Managed directory sync exposes added, updated, removed, and unchanged item categories so install reporting can describe concrete changes instead of only counts. + +## Impact + +- **Code**: `src/commands/install.ts`, `src/install/managed.ts`, and supporting tests in `src/install/managed.test.ts` and `src/integration.test.ts`. +- **Docs**: `README.md` should describe the richer install output. +- **APIs**: No external API changes; CLI output becomes more descriptive. +- **Dependencies**: None expected. diff --git a/openspec/changes/show-install-update-changes/specs/cli-commands/spec.md b/openspec/changes/show-install-update-changes/specs/cli-commands/spec.md new file mode 100644 index 0000000..2eb2bdd --- /dev/null +++ b/openspec/changes/show-install-update-changes/specs/cli-commands/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: CLI provides install command +The CLI SHALL provide an `install` command that reads dependency configurations, clones/pulls repositories to the cache, discovers skills and subagents, installs them for all configured agents, and reports managed items that were added, updated, or removed when install changes occur. + +#### Scenario: Install with project agents.yaml +- **WHEN** user runs `agentdeps install` in a directory containing `agents.yaml` +- **THEN** the tool processes all dependencies, caches repos, and installs skills into `skills/_agentdeps_managed/` and subagents into `agents/_agentdeps_managed/` for each configured agent + +#### Scenario: Install without agents.yaml +- **WHEN** user runs `agentdeps install` in a directory without `agents.yaml` +- **THEN** the tool processes only the global `agents.yaml` (if it exists) and prints a message that no project dependencies were found + +#### Scenario: Install without global config +- **WHEN** user runs `agentdeps install` and no `~/.config/agentdeps/config.yaml` exists +- **THEN** the tool triggers the interactive setup before proceeding with installation + +#### Scenario: Install via npx +- **WHEN** user runs `npx agentdeps install` +- **THEN** the tool works identically to a globally installed version, with no additional setup required beyond the first-run config + +#### Scenario: Install reports managed item changes after dependency updates +- **WHEN** user runs `agentdeps install` and the resolved install state changes for a managed target after dependency updates or dependency selection changes +- **THEN** the output includes non-empty added, updated, and removed groups naming the affected managed skills and agents for that target + +#### Scenario: Install stays concise when nothing changed +- **WHEN** user runs `agentdeps install` and all managed items already match the resolved dependencies +- **THEN** the output indicates the relevant scope or target is up to date without printing empty change groups diff --git a/openspec/changes/show-install-update-changes/specs/install-management/spec.md b/openspec/changes/show-install-update-changes/specs/install-management/spec.md new file mode 100644 index 0000000..b4c6543 --- /dev/null +++ b/openspec/changes/show-install-update-changes/specs/install-management/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Managed sync classifies item outcomes +The tool SHALL classify each top-level managed skill or agent as added, updated, removed, or unchanged during sync so install reporting can describe concrete item changes. + +#### Scenario: First install is classified as added +- **WHEN** a desired managed skill or agent does not yet exist in `_agentdeps_managed/` +- **THEN** the sync result classifies that item as added + +#### Scenario: Replaced symlink is classified as updated +- **WHEN** link-mode install finds an existing managed symlink that points to a different target than the desired cached item +- **THEN** the sync result classifies that item as updated + +#### Scenario: Copy sync mutations are classified as updated +- **WHEN** copy-mode install finds an existing managed item and smart sync overwrites, creates, or removes nested files or directories while reconciling it with the source +- **THEN** the sync result classifies that item as updated + +#### Scenario: Pruned item is classified as removed +- **WHEN** an item exists in `_agentdeps_managed/` but is no longer part of the desired managed set +- **THEN** the sync result classifies that item as removed + +#### Scenario: Matching item is classified as unchanged +- **WHEN** an existing managed item already matches the desired source without any filesystem mutations +- **THEN** the sync result classifies that item as unchanged diff --git a/openspec/changes/show-install-update-changes/tasks.md b/openspec/changes/show-install-update-changes/tasks.md new file mode 100644 index 0000000..82289e1 --- /dev/null +++ b/openspec/changes/show-install-update-changes/tasks.md @@ -0,0 +1,21 @@ +## 1. Add failing managed-sync tests + +- [x] 1.1 Update `src/install/managed.test.ts` to cover added, updated, removed, and unchanged outcomes in link mode +- [x] 1.2 Update `src/install/copy.test.ts` and/or `src/install/managed.test.ts` to cover copy-mode targets that truly change versus targets that remain unchanged + +## 2. Implement managed item change classification + +- [x] 2.1 Change `src/install/copy.ts` so `smartSync()` reports whether it mutated the destination for file and directory syncs +- [x] 2.2 Extend `src/install/managed.ts` `SyncSummary` and sync logic to populate `updated` separately from `added`, `removed`, and `unchanged` +- [x] 2.3 Update any dependent types or helpers so link-mode replacements and copy-mode mutations are classified consistently + +## 3. Add failing install output tests + +- [x] 3.1 Add `src/commands/install.test.ts` coverage for install summaries that list non-empty added, updated, and removed groups while keeping no-op output concise +- [x] 3.2 Extend `src/integration.test.ts` to verify `agentdeps install` prints changed managed skill names after an update + +## 4. Implement CLI reporting and docs + +- [x] 4.1 Update `src/commands/install.ts` to carry detailed change lists through install results and print itemized output for changed targets +- [x] 4.2 Update `README.md` to document the richer install output +- [x] 4.3 Run `bunx tsc --noEmit`, `bun test`, and `bun run build` diff --git a/src/cache/cache.ts b/src/cache/cache.ts index 5b0e7b9..41bd1f5 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -22,19 +22,48 @@ export function getCacheDir(): string { const localAppData = process.env["LOCALAPPDATA"] ?? join(home, "AppData", "Local"); return join(localAppData, "agentdeps", "repos"); } - // Linux and others: XDG_CACHE_HOME or default const xdg = process.env["XDG_CACHE_HOME"]; return join(xdg ?? join(home, ".cache"), "agentdeps", "repos"); } -/** Run a git command and return { success, stdout, stderr } */ +function getSafeCwd(): string { + try { + return process.cwd(); + } catch { + return homedir(); + } +} + +const GIT_ENV_VARS_TO_UNSET = [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_NAMESPACE", + "GIT_PREFIX", + "GIT_SUPER_PREFIX", +] as const; + +function gitSubprocessEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const key of GIT_ENV_VARS_TO_UNSET) { + delete env[key]; + } + return env; +} + + + async function runGit( args: string[], cwd?: string ): Promise<{ success: boolean; stdout: string; stderr: string }> { return new Promise((resolve) => { const proc = spawn("git", args, { - cwd, + cwd: cwd ?? getSafeCwd(), + env: gitSubprocessEnv(), stdio: ["ignore", "pipe", "pipe"], }); @@ -62,6 +91,37 @@ async function runGit( }); } +async function getHeadRevision(repoPath: string): Promise { + const result = await runGit(["rev-parse", "HEAD"], repoPath); + if (!result.success || result.stdout.length === 0) { + return undefined; + } + return result.stdout; +} + +async function getChangedPaths( + repoPath: string, + beforeRevision: string, + afterRevision: string +): Promise { + if (beforeRevision === afterRevision) { + return []; + } + + const result = await runGit( + ["diff", "--name-only", beforeRevision, afterRevision, "--", "skills", "agents"], + repoPath + ); + if (!result.success || result.stdout.length === 0) { + return []; + } + + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + /** Check if git is available in PATH */ export async function checkGitAvailable(): Promise { try { @@ -72,7 +132,6 @@ export async function checkGitAvailable(): Promise { } } -/** Check if a directory exists */ async function dirExists(path: string): Promise { try { const s = await fsStat(path); @@ -82,15 +141,11 @@ async function dirExists(path: string): Promise { } } -/** - * Clone a repository to the cache directory. - * Uses --branch and --single-branch for efficiency. - */ export async function cloneRepo( url: string, ref: string, cacheKey: string -): Promise<{ success: boolean; path: string; error?: string }> { +): Promise<{ success: boolean; path: string; error?: string; changedPaths: string[] }> { const cacheDir = getCacheDir(); await mkdir(cacheDir, { recursive: true }); @@ -106,84 +161,83 @@ export async function cloneRepo( ]); if (!result.success) { - // Clean up any partial directory left by the failed clone await rm(repoPath, { recursive: true, force: true }); - // Fallback: try clone without --branch (for commit SHAs) const fallback = await runGit(["clone", url, repoPath]); if (fallback.success) { const checkout = await runGit(["checkout", ref], repoPath); if (!checkout.success) { - // Checkout failed — ref doesn't exist. Clean up the cloned directory - // to prevent stale content from being used on subsequent runs. await rm(repoPath, { recursive: true, force: true }); - return { success: false, path: repoPath, error: `ref '${ref}' not found: ${checkout.stderr}` }; + return { + success: false, + path: repoPath, + error: `ref '${ref}' not found: ${checkout.stderr}`, + changedPaths: [], + }; } - return { success: true, path: repoPath }; + return { success: true, path: repoPath, changedPaths: [] }; } - return { success: false, path: repoPath, error: result.stderr }; + return { success: false, path: repoPath, error: result.stderr, changedPaths: [] }; } - return { success: true, path: repoPath }; + return { success: true, path: repoPath, changedPaths: [] }; } -/** - * Update an existing cached repository. - * Fetches from origin and resets to the configured ref. - * Uses `reset --hard` to avoid detached HEAD state for branch refs. - */ export async function updateRepo( repoPath: string, ref: string -): Promise<{ success: boolean; error?: string }> { - // Fetch latest +): Promise<{ success: boolean; error?: string; changedPaths: string[] }> { + const beforeRevision = await getHeadRevision(repoPath); + const fetch = await runGit(["fetch", "origin"], repoPath); if (!fetch.success) { - return { success: false, error: fetch.stderr }; + return { success: false, error: fetch.stderr, changedPaths: [] }; } - // Try resetting to remote branch first (origin/) const resetBranch = await runGit( ["reset", "--hard", `origin/${ref}`], repoPath ); - if (resetBranch.success) { - return { success: true }; + if (!resetBranch.success) { + const checkoutRef = await runGit(["checkout", ref], repoPath); + if (!checkoutRef.success) { + return { success: false, error: checkoutRef.stderr, changedPaths: [] }; + } } - // Fall back to tag or SHA (checkout is fine for these — they're always detached) - const checkoutRef = await runGit(["checkout", ref], repoPath); - if (!checkoutRef.success) { - return { success: false, error: checkoutRef.stderr }; + const afterRevision = await getHeadRevision(repoPath); + if (!beforeRevision || !afterRevision) { + return { success: true, changedPaths: [] }; } - return { success: true }; + return { + success: true, + changedPaths: await getChangedPaths(repoPath, beforeRevision, afterRevision), + }; } -/** - * Ensure a repository is cloned and up-to-date. - * Clones if missing, updates if exists. - * Returns the cache path. - */ export async function ensureRepo( url: string, ref: string, cacheKey: string -): Promise<{ success: boolean; path: string; error?: string }> { +): Promise<{ success: boolean; path: string; error?: string; changedPaths: string[] }> { const cacheDir = getCacheDir(); const repoPath = join(cacheDir, cacheKey); if (await dirExists(repoPath)) { - // Update existing const result = await updateRepo(repoPath, ref); if (!result.success) { logError("cache.update", new Error(`Failed to update ${cacheKey}: ${result.error}`)); - return { success: false, path: repoPath, error: `Failed to update ${cacheKey}: ${result.error}` }; + return { + success: false, + path: repoPath, + error: `Failed to update ${cacheKey}: ${result.error}`, + changedPaths: [], + }; } - return { success: true, path: repoPath }; + return { success: true, path: repoPath, changedPaths: result.changedPaths }; } - // Clone fresh const result = await cloneRepo(url, ref, cacheKey); if (!result.success) { logError("cache.clone", new Error(`Failed to clone ${url}: ${result.error}`)); diff --git a/src/commands/install.test.ts b/src/commands/install.test.ts new file mode 100644 index 0000000..82d59c4 --- /dev/null +++ b/src/commands/install.test.ts @@ -0,0 +1,109 @@ +/** + * Unit tests for install output formatting. + */ +import { describe, it, expect } from "bun:test"; +import { formatInstallResults, type AgentInstallResult } from "./install.ts"; + +describe("formatInstallResults", () => { + it("lists non-empty added, updated, and removed groups", () => { + const results: AgentInstallResult[] = [ + { + displayNames: ["Pi"], + skills: { + added: ["new-skill"], + updated: ["changed-skill"], + removed: ["old-skill"], + unchanged: [], + }, + agents: { + added: ["new-agent"], + updated: ["helper-agent"], + removed: ["old-agent"], + unchanged: [], + }, + }, + ]; + + const output = formatInstallResults("Project", results); + + expect(output).toContain( + "Project (Pi): 1 skill added, 1 skill updated, 1 skill removed, 1 agent added, 1 agent updated, 1 agent removed" + ); + expect(output).toContain("skills added: new-skill"); + expect(output).toContain("skills updated: changed-skill"); + expect(output).toContain("skills removed: old-skill"); + expect(output).toContain("agents added: new-agent"); + expect(output).toContain("agents updated: helper-agent"); + expect(output).toContain("agents removed: old-agent"); + expect(output).not.toContain("unchanged"); + }); + + it("returns a nothing-to-do message when there are no targets", () => { + expect(formatInstallResults("Project", [])).toBe(" ✓ Project: nothing to do"); + }); + + it("keeps no-op output concise", () => { + const results: AgentInstallResult[] = [ + { + displayNames: ["Pi"], + skills: { + added: [], + updated: [], + removed: [], + unchanged: ["my-skill"], + }, + agents: { + added: [], + updated: [], + removed: [], + unchanged: ["helper-agent"], + }, + }, + ]; + + expect(formatInstallResults("Project", results)).toBe(" ✓ Project (Pi): up to date"); + }); + + it("formats multiple targets with per-target detail", () => { + const results: AgentInstallResult[] = [ + { + displayNames: ["Pi"], + skills: { + added: [], + updated: [], + removed: [], + unchanged: ["my-skill"], + }, + agents: { + added: [], + updated: [], + removed: [], + unchanged: [], + }, + }, + { + displayNames: ["Claude Code"], + skills: { + added: [], + updated: ["changed-skill"], + removed: [], + unchanged: [], + }, + agents: { + added: [], + updated: [], + removed: ["removed-agent"], + unchanged: [], + }, + }, + ]; + + const output = formatInstallResults("Project", results); + + expect(output).toContain(" ✓ Project:"); + expect(output).toContain("Pi: up to date"); + expect(output).toContain("Claude Code: 1 skill updated, 1 agent removed"); + expect(output).toContain("skills updated: changed-skill"); + expect(output).toContain("agents removed: removed-agent"); + }); +}); diff --git a/src/commands/install.ts b/src/commands/install.ts index 2006fe2..7cde9f3 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -31,27 +31,114 @@ import { validateAgentNames, type LabeledAgentPaths, } from "../registry/registry.ts"; -import { syncManagedDir, expandHomePath } from "../install/managed.ts"; +import { syncManagedDir, expandHomePath, type SyncSummary } from "../install/managed.ts"; import { cleanupLegacyManagedDirs } from "../install/migration.ts"; import { logError, printLogHint } from "../log/logger.ts"; -/** Resolved items from a single dependency */ interface ResolvedDep { repo: string; cachePath: string; skills: DiscoveredItem[]; agents: DiscoveredItem[]; + updatedSkillNames: string[]; + updatedAgentNames: string[]; +} + +export interface AgentInstallResult { + displayNames: string[]; + skills: SyncSummary; + agents: SyncSummary; +} + +function changedTopLevelItems( + changedPaths: readonly string[], + kind: "skills" | "agents" +): Set { + const names = new Set(); + const prefix = `${kind}/`; + + for (const changedPath of changedPaths) { + if (!changedPath.startsWith(prefix)) { + continue; + } + + const relativePath = changedPath.slice(prefix.length); + const [firstSegment] = relativePath.split("/"); + if (!firstSegment) { + continue; + } + + if (kind === "agents" && firstSegment.endsWith(".md")) { + names.add(firstSegment.replace(/\.md$/, "")); + } else { + names.add(firstSegment); + } + } + + return names; +} + +function mergeReportedUpdates( + summary: SyncSummary, + updatedNames: readonly string[] +): SyncSummary { + const added = [...summary.added]; + const updated = [...summary.updated]; + const removed = [...summary.removed]; + const unchanged = [...summary.unchanged]; + + for (const name of updatedNames) { + if (added.includes(name) || removed.includes(name) || updated.includes(name)) { + continue; + } + + const unchangedIndex = unchanged.indexOf(name); + if (unchangedIndex !== -1) { + unchanged.splice(unchangedIndex, 1); + } + updated.push(name); + } + + return { added, updated, removed, unchanged }; +} + +function collectRepoUpdatedNames( + resolvedDeps: readonly ResolvedDep[], + desiredItems: Map, + kind: "skills" | "agents" +): string[] { + const names: string[] = []; + const seen = new Set(); + + for (const dep of resolvedDeps) { + const items = kind === "skills" ? dep.skills : dep.agents; + const updatedNames = new Set( + kind === "skills" ? dep.updatedSkillNames : dep.updatedAgentNames + ); + + for (const item of items) { + if (!updatedNames.has(item.name)) { + continue; + } + if (desiredItems.get(item.name) !== item.sourcePath) { + continue; + } + if (seen.has(item.name)) { + continue; + } + + seen.add(item.name); + names.push(item.name); + } + } + + return names; } -/** - * Process a list of dependencies: cache repos, discover, and filter. - * Parallelizes repo caching for better performance with multiple deps. - */ async function resolveDeps( deps: readonly Dependency[], cloneMethod: "ssh" | "https" ): Promise { - // Phase 1: Cache all repos in parallel const cacheResults = await Promise.all( deps.map(async (dep) => { const url = resolveRepoUrl(dep.repo, cloneMethod); @@ -62,7 +149,6 @@ async function resolveDeps( }) ); - // Phase 2: Discover and filter (parallel per dep) const resolved = await Promise.all( cacheResults.map(async ({ dep, result }) => { if (!result.success) { @@ -70,17 +156,16 @@ async function resolveDeps( return null; } - // Discover (parallel — independent operations) const [discoveredSkills, discoveredAgents] = await Promise.all([ discoverSkills(result.path), discoverAgents(result.path), ]); - // Filter const skillResult = filterItems(discoveredSkills, dep.skills); const agentResult = filterItems(discoveredAgents, dep.agents); + const changedSkills = changedTopLevelItems(result.changedPaths, "skills"); + const changedAgents = changedTopLevelItems(result.changedPaths, "agents"); - // Warn on issues warnDiscoveryIssues(dep.repo, "skills", discoveredSkills, dep.skills, skillResult.missing); warnDiscoveryIssues(dep.repo, "agents", discoveredAgents, dep.agents, agentResult.missing); @@ -89,36 +174,25 @@ async function resolveDeps( cachePath: result.path, skills: skillResult.selected, agents: agentResult.selected, + updatedSkillNames: skillResult.selected + .filter((item) => changedSkills.has(item.name)) + .map((item) => item.name), + updatedAgentNames: agentResult.selected + .filter((item) => changedAgents.has(item.name)) + .map((item) => item.name), }; }) ); - // Filter out failed deps return resolved.filter((r): r is ResolvedDep => r !== null); } -/** Per-agent install result */ -interface AgentInstallResult { - displayNames: string[]; - skillsAdded: number; - skillsRemoved: number; - agentsAdded: number; - agentsRemoved: number; -} - -/** - * Install resolved deps to a set of managed directories. - * Returns per-agent results for clear reporting. - * Deduplicates directories to avoid redundant installs (e.g., universal agents - * sharing project paths but differing in global paths). - */ async function installToManagedDirs( resolvedDeps: readonly ResolvedDep[], labeledPaths: LabeledAgentPaths[], scope: "project" | "global", installMethod: "link" | "copy" ): Promise { - // Build desired item maps const desiredSkills = new Map(); const desiredAgents = new Map(); @@ -131,8 +205,13 @@ async function installToManagedDirs( } } - // Deduplicate by actual target directories for the given scope, - // merging display names when multiple agents share the same dirs. + const repoUpdatedSkills = installMethod === "link" + ? collectRepoUpdatedNames(resolvedDeps, desiredSkills, "skills") + : []; + const repoUpdatedAgents = installMethod === "link" + ? collectRepoUpdatedNames(resolvedDeps, desiredAgents, "agents") + : []; + const dedupMap = new Map(); for (const labeled of labeledPaths) { const skillsDir = scope === "global" @@ -162,76 +241,95 @@ async function installToManagedDirs( results.push({ displayNames, - skillsAdded: skillSummary.added.length, - skillsRemoved: skillSummary.removed.length, - agentsAdded: agentSummary.added.length, - agentsRemoved: agentSummary.removed.length, + skills: mergeReportedUpdates(skillSummary, repoUpdatedSkills), + agents: mergeReportedUpdates(agentSummary, repoUpdatedAgents), }); } return results; } -/** - * Format install results for display. - * When all agents have the same counts, shows a single summary line. - * Otherwise shows per-agent breakdowns. - */ -function formatInstallResults(scope: string, results: AgentInstallResult[]): string { +function countPhrase(count: number, noun: "skill" | "agent", action: "added" | "updated" | "removed"): string { + return `${count} ${noun}${count === 1 ? "" : "s"} ${action}`; +} + +function detailLines(indent: string, kind: "skills" | "agents", summary: SyncSummary): string[] { + const lines: string[] = []; + if (summary.added.length > 0) lines.push(`${indent}${kind} added: ${summary.added.join(", ")}`); + if (summary.updated.length > 0) lines.push(`${indent}${kind} updated: ${summary.updated.join(", ")}`); + if (summary.removed.length > 0) lines.push(`${indent}${kind} removed: ${summary.removed.join(", ")}`); + return lines; +} + +function summaryParts(result: AgentInstallResult): string[] { + const parts: string[] = []; + + if (result.skills.added.length > 0) { + parts.push(countPhrase(result.skills.added.length, "skill", "added")); + } + if (result.skills.updated.length > 0) { + parts.push(countPhrase(result.skills.updated.length, "skill", "updated")); + } + if (result.skills.removed.length > 0) { + parts.push(countPhrase(result.skills.removed.length, "skill", "removed")); + } + if (result.agents.added.length > 0) { + parts.push(countPhrase(result.agents.added.length, "agent", "added")); + } + if (result.agents.updated.length > 0) { + parts.push(countPhrase(result.agents.updated.length, "agent", "updated")); + } + if (result.agents.removed.length > 0) { + parts.push(countPhrase(result.agents.removed.length, "agent", "removed")); + } + + return parts; +} + +export function formatInstallResults(scope: string, results: AgentInstallResult[]): string { if (results.length === 0) { return ` ✓ ${scope}: nothing to do`; } if (results.length === 1) { - const r = results[0]!; - const removed = r.skillsRemoved + r.agentsRemoved; - const parts: string[] = []; - if (r.skillsAdded > 0) parts.push(`${r.skillsAdded} skills added`); - if (r.agentsAdded > 0) parts.push(`${r.agentsAdded} agents added`); - if (removed > 0) parts.push(`${removed} removed`); - if (parts.length === 0) parts.push("up to date"); - const label = r.displayNames.join(", "); - return ` ✓ ${scope} (${label}): ${parts.join(", ")}`; + const result = results[0]!; + const label = result.displayNames.join(", "); + const parts = summaryParts(result); + if (parts.length === 0) { + return ` ✓ ${scope} (${label}): up to date`; + } + + const details = [ + ...detailLines(" ", "skills", result.skills), + ...detailLines(" ", "agents", result.agents), + ]; + return ` ✓ ${scope} (${label}): ${parts.join(", ")}\n${details.join("\n")}`; } - // Multiple agents — show per-agent lines const lines: string[] = []; - for (const r of results) { - const removed = r.skillsRemoved + r.agentsRemoved; - const parts: string[] = []; - if (r.skillsAdded > 0) parts.push(`${r.skillsAdded} skills added`); - if (r.agentsAdded > 0) parts.push(`${r.agentsAdded} agents added`); - if (removed > 0) parts.push(`${removed} removed`); - if (parts.length === 0) parts.push("up to date"); - const label = r.displayNames.join(", "); - lines.push(` ${label}: ${parts.join(", ")}`); + for (const result of results) { + const label = result.displayNames.join(", "); + const parts = summaryParts(result); + lines.push(` ${label}: ${parts.length === 0 ? "up to date" : parts.join(", ")}`); + lines.push(...detailLines(" ", "skills", result.skills)); + lines.push(...detailLines(" ", "agents", result.agents)); } return ` ✓ ${scope}:\n${lines.join("\n")}`; } -/** - * Run the full install flow. - */ export async function runInstall(config: GlobalConfig): Promise { - // Merge custom agents if any if (config.custom_agents) { mergeCustomAgents(config.custom_agents); } - // Validate agent names const unknown = validateAgentNames(config.agents); if (unknown.length > 0) { console.warn(`⚠ Unknown agents in config: ${unknown.join(", ")}`); } - // Resolve agent paths (with deduplication and labels) const labeledPaths = resolveAgentPathsLabeled(config.agents); - - // Clean up legacy managed directories for migrated agents await cleanupLegacyManagedDirs(config.agents); - - // 1. Process global agents.yaml const globalYamlPath = globalAgentsYamlPath(); try { @@ -241,7 +339,6 @@ export async function runInstall(config: GlobalConfig): Promise { if (globalConfig.dependencies.length > 0) { const resolved = await resolveDeps(globalConfig.dependencies, config.clone_method); - const results = await installToManagedDirs(resolved, labeledPaths, "global", config.install_method); console.log(formatInstallResults("Global", results)); } @@ -251,7 +348,6 @@ export async function runInstall(config: GlobalConfig): Promise { console.warn("⚠ Failed to process global dependencies"); } - // 2. Process project agents.yaml const projectYamlPath = join(process.cwd(), "agents.yaml"); if (await projectConfigExists(projectYamlPath)) { @@ -260,7 +356,6 @@ export async function runInstall(config: GlobalConfig): Promise { if (projectConfig.dependencies.length > 0) { const resolved = await resolveDeps(projectConfig.dependencies, config.clone_method); - const results = await installToManagedDirs(resolved, labeledPaths, "project", config.install_method); console.log(formatInstallResults("Project", results)); } else { diff --git a/src/install/copy.test.ts b/src/install/copy.test.ts index a349a16..fce94cb 100644 --- a/src/install/copy.test.ts +++ b/src/install/copy.test.ts @@ -30,64 +30,92 @@ afterEach(async () => { }); describe("smartSync", () => { - it("copies new files", async () => { + it("returns true when it copies new files", async () => { await writeFile(join(srcDir, "file1.txt"), "hello"); await writeFile(join(srcDir, "file2.txt"), "world"); - await smartSync(srcDir, dstDir); + const changed = await smartSync(srcDir, dstDir); const content1 = await readFile(join(dstDir, "file1.txt"), "utf-8"); const content2 = await readFile(join(dstDir, "file2.txt"), "utf-8"); + expect(changed).toBe(true); expect(content1).toBe("hello"); expect(content2).toBe("world"); }); - it("copies subdirectories recursively", async () => { + it("returns true when it copies subdirectories recursively", async () => { await mkdir(join(srcDir, "sub"), { recursive: true }); await writeFile(join(srcDir, "sub", "nested.txt"), "nested"); - await smartSync(srcDir, dstDir); + const changed = await smartSync(srcDir, dstDir); const content = await readFile(join(dstDir, "sub", "nested.txt"), "utf-8"); + expect(changed).toBe(true); expect(content).toBe("nested"); }); - it("overwrites changed files", async () => { - await writeFile(join(srcDir, "file.txt"), "original"); + it("returns false when the destination already matches the source", async () => { + await writeFile(join(srcDir, "file.txt"), "unchanged"); await smartSync(srcDir, dstDir); - // Modify source - await writeFile(join(srcDir, "file.txt"), "updated content"); + const changed = await smartSync(srcDir, dstDir); + + expect(changed).toBe(false); + }); + + it("returns true when it overwrites changed files", async () => { + await writeFile(join(srcDir, "file.txt"), "original"); await smartSync(srcDir, dstDir); + await writeFile(join(srcDir, "file.txt"), "updated content that is longer"); + const changed = await smartSync(srcDir, dstDir); + const content = await readFile(join(dstDir, "file.txt"), "utf-8"); - expect(content).toBe("updated content"); + expect(changed).toBe(true); + expect(content).toBe("updated content that is longer"); }); - it("removes deleted files", async () => { + it("returns true when it removes deleted files", async () => { await writeFile(join(srcDir, "keep.txt"), "keep"); await writeFile(join(srcDir, "remove.txt"), "remove"); await smartSync(srcDir, dstDir); - // Remove from source await rm(join(srcDir, "remove.txt")); - await smartSync(srcDir, dstDir); + const changed = await smartSync(srcDir, dstDir); const entries = await readdir(dstDir); + expect(changed).toBe(true); expect(entries).toEqual(["keep.txt"]); }); - it("removes deleted subdirectories", async () => { + it("returns true when it removes deleted subdirectories", async () => { await mkdir(join(srcDir, "sub"), { recursive: true }); await writeFile(join(srcDir, "sub", "file.txt"), "content"); await writeFile(join(srcDir, "root.txt"), "root"); await smartSync(srcDir, dstDir); - // Remove subdirectory from source await rm(join(srcDir, "sub"), { recursive: true }); - await smartSync(srcDir, dstDir); + const changed = await smartSync(srcDir, dstDir); const entries = await readdir(dstDir); + expect(changed).toBe(true); expect(entries).toEqual(["root.txt"]); }); + + it("supports single-file sync and reports whether it changed", async () => { + const srcFile = join(tempDir, "agent.md"); + const outDir = join(tempDir, "out"); + const dstFile = join(outDir, "agent.md"); + await mkdir(outDir, { recursive: true }); + await writeFile(srcFile, "# Agent"); + + expect(await smartSync(srcFile, dstFile)).toBe(true); + expect(await readFile(dstFile, "utf-8")).toBe("# Agent"); + + expect(await smartSync(srcFile, dstFile)).toBe(false); + + await writeFile(srcFile, "# Agent updated"); + expect(await smartSync(srcFile, dstFile)).toBe(true); + expect(await readFile(dstFile, "utf-8")).toBe("# Agent updated"); + }); }); diff --git a/src/install/copy.ts b/src/install/copy.ts index 01f39a8..ec4d85b 100644 --- a/src/install/copy.ts +++ b/src/install/copy.ts @@ -20,30 +20,30 @@ import { join } from "node:path"; * Sync source to dest. Handles both files and directories. * - If source is a file, copies it directly to dest. * - If source is a directory, recursively syncs so dest mirrors source exactly. + * + * Returns true when the destination was mutated, false when it was already up to date. */ export async function smartSync( source: string, dest: string -): Promise { +): Promise { const srcStat = await stat(source); if (srcStat.isFile()) { - // Single file sync const needsCopy = await fileNeedsCopy(source, dest); if (needsCopy) { await copyFile(source, dest); + return true; } - return; + return false; } - // Directory sync + let changed = !(await pathExists(dest)); await mkdir(dest, { recursive: true }); - // Get source entries const sourceEntries = await readdir(source, { withFileTypes: true }); const sourceNames = new Set(sourceEntries.map((e) => e.name)); - // Get dest entries (may not exist yet) let destEntries: Dirent[] = []; try { destEntries = await readdir(dest, { withFileTypes: true }); @@ -51,39 +51,50 @@ export async function smartSync( // Dest doesn't exist yet, that's fine } - // Remove items in dest that are not in source for (const entry of destEntries) { if (!sourceNames.has(entry.name)) { await rm(join(dest, entry.name), { recursive: true, force: true }); + changed = true; } } - // Sync source items for (const entry of sourceEntries) { const srcPath = join(source, entry.name); const dstPath = join(dest, entry.name); if (entry.isDirectory()) { - await smartSync(srcPath, dstPath); + if (await smartSync(srcPath, dstPath)) { + changed = true; + } } else if (entry.isFile()) { const needsCopy = await fileNeedsCopy(srcPath, dstPath); if (needsCopy) { await copyFile(srcPath, dstPath); + changed = true; } } } + + return changed; } /** Check if a file needs to be copied (missing or different size/mtime) */ async function fileNeedsCopy(src: string, dst: string): Promise { try { const [srcStat, dstStat] = await Promise.all([stat(src), stat(dst)]); - // Compare size first (fast), then mtime if (srcStat.size !== dstStat.size) return true; if (srcStat.mtimeMs > dstStat.mtimeMs) return true; return false; } catch { - // Dest doesn't exist return true; } } + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} diff --git a/src/install/managed.test.ts b/src/install/managed.test.ts index d80724b..1e53c05 100644 --- a/src/install/managed.test.ts +++ b/src/install/managed.test.ts @@ -7,14 +7,13 @@ import { mkdir, rm, writeFile, - readdir, readlink, lstat, readFile, } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { syncManagedDir } from "./managed.ts"; +import { syncManagedDir, expandHomePath } from "./managed.ts"; let tempDir: string; @@ -26,6 +25,14 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); +describe("expandHomePath", () => { + it("expands the home shorthand and leaves other paths unchanged", () => { + expect(expandHomePath("~/agentdeps-test")).not.toBe("~/agentdeps-test"); + expect(expandHomePath("/tmp/agentdeps-test")).toBe("/tmp/agentdeps-test"); + }); +}); + + describe("syncManagedDir — link mode", () => { it("creates symlinks for desired items", async () => { const managedDir = join(tempDir, "_agentdeps_managed"); @@ -44,35 +51,52 @@ describe("syncManagedDir — link mode", () => { const summary = await syncManagedDir(managedDir, desired, "link"); expect(summary.added).toEqual(["skill-1", "skill-2"]); + expect(summary.updated).toEqual([]); expect(summary.removed).toEqual([]); + expect(summary.unchanged).toEqual([]); - // Verify symlinks const stat1 = await lstat(join(managedDir, "skill-1")); expect(stat1.isSymbolicLink()).toBe(true); const target1 = await readlink(join(managedDir, "skill-1")); expect(target1).toBe(source1); }); - it("prunes stale items", async () => { + it("classifies added, updated, removed, and unchanged items", async () => { const managedDir = join(tempDir, "_agentdeps_managed"); - const source = join(tempDir, "source"); - await mkdir(source); - await writeFile(join(source, "file.md"), "content"); - - // Initial install with two items - const desired1 = new Map([ - ["keep", source], - ["remove", source], - ]); - await syncManagedDir(managedDir, desired1, "link"); + const keepSource = join(tempDir, "keep-source"); + const oldUpdateSource = join(tempDir, "old-update-source"); + const newUpdateSource = join(tempDir, "new-update-source"); + const addSource = join(tempDir, "add-source"); + + for (const dir of [keepSource, oldUpdateSource, newUpdateSource, addSource]) { + await mkdir(dir); + await writeFile(join(dir, "file.md"), dir); + } + + await syncManagedDir( + managedDir, + new Map([ + ["keep", keepSource], + ["update", oldUpdateSource], + ["remove", oldUpdateSource], + ]), + "link" + ); - // Remove one - const desired2 = new Map([["keep", source]]); - const summary = await syncManagedDir(managedDir, desired2, "link"); + const summary = await syncManagedDir( + managedDir, + new Map([ + ["keep", keepSource], + ["update", newUpdateSource], + ["add", addSource], + ]), + "link" + ); + expect(summary.added).toEqual(["add"]); + expect(summary.updated).toEqual(["update"]); expect(summary.removed).toEqual(["remove"]); - const entries = await readdir(managedDir); - expect(entries).toEqual(["keep"]); + expect(summary.unchanged).toEqual(["keep"]); }); it("is idempotent", async () => { @@ -86,6 +110,7 @@ describe("syncManagedDir — link mode", () => { const summary = await syncManagedDir(managedDir, desired, "link"); expect(summary.added).toEqual([]); + expect(summary.updated).toEqual([]); expect(summary.removed).toEqual([]); expect(summary.unchanged).toEqual(["skill"]); }); @@ -102,8 +127,10 @@ describe("syncManagedDir — copy mode", () => { const summary = await syncManagedDir(managedDir, desired, "copy"); expect(summary.added).toEqual(["my-skill"]); + expect(summary.updated).toEqual([]); + expect(summary.removed).toEqual([]); + expect(summary.unchanged).toEqual([]); - // Verify copy (not symlink) const stat = await lstat(join(managedDir, "my-skill")); expect(stat.isSymbolicLink()).toBe(false); expect(stat.isDirectory()).toBe(true); @@ -115,6 +142,31 @@ describe("syncManagedDir — copy mode", () => { expect(content).toBe("# My Skill"); }); + it("classifies updated and unchanged items for existing targets", async () => { + const managedDir = join(tempDir, "_agentdeps_managed"); + const stableSource = join(tempDir, "stable-source"); + const changingSource = join(tempDir, "changing-source"); + await mkdir(stableSource); + await mkdir(changingSource); + await writeFile(join(stableSource, "SKILL.md"), "# Stable Skill"); + await writeFile(join(changingSource, "SKILL.md"), "# Old Skill"); + + const desired = new Map([ + ["stable", stableSource], + ["changing", changingSource], + ]); + + await syncManagedDir(managedDir, desired, "copy"); + + await writeFile(join(changingSource, "SKILL.md"), "# Updated Skill with more content"); + const summary = await syncManagedDir(managedDir, desired, "copy"); + + expect(summary.added).toEqual([]); + expect(summary.updated).toEqual(["changing"]); + expect(summary.removed).toEqual([]); + expect(summary.unchanged).toEqual(["stable"]); + }); + it("prunes stale items in copy mode", async () => { const managedDir = join(tempDir, "_agentdeps_managed"); const source = join(tempDir, "source"); @@ -130,6 +182,43 @@ describe("syncManagedDir — copy mode", () => { const desired2 = new Map([["keep", source]]); const summary = await syncManagedDir(managedDir, desired2, "copy"); + expect(summary.added).toEqual([]); + expect(summary.updated).toEqual([]); expect(summary.removed).toEqual(["remove"]); + expect(summary.unchanged).toEqual(["keep"]); + }); + + it("is idempotent in copy mode", async () => { + const managedDir = join(tempDir, "_agentdeps_managed"); + const source = join(tempDir, "source"); + await mkdir(source); + await writeFile(join(source, "SKILL.md"), "# My Skill"); + + const desired = new Map([["my-skill", source]]); + await syncManagedDir(managedDir, desired, "copy"); + const summary = await syncManagedDir(managedDir, desired, "copy"); + + expect(summary.added).toEqual([]); + expect(summary.updated).toEqual([]); + expect(summary.removed).toEqual([]); + expect(summary.unchanged).toEqual(["my-skill"]); + }); + + it("preserves file extensions for file-based items and removes empty managed dirs", async () => { + const managedDir = join(tempDir, "_agentdeps_managed"); + const sourceFile = join(tempDir, "helper-agent.md"); + await writeFile(sourceFile, "# Helper Agent"); + + const installSummary = await syncManagedDir( + managedDir, + new Map([["helper-agent", sourceFile]]), + "copy" + ); + expect(installSummary.added).toEqual(["helper-agent"]); + expect(await readFile(join(managedDir, "helper-agent.md"), "utf-8")).toBe("# Helper Agent"); + + const removeSummary = await syncManagedDir(managedDir, new Map(), "copy"); + expect(removeSummary.removed).toEqual(["helper-agent.md"]); + await expect(lstat(managedDir)).rejects.toThrow(); }); }); diff --git a/src/install/managed.ts b/src/install/managed.ts index f35113c..f0b980d 100644 --- a/src/install/managed.ts +++ b/src/install/managed.ts @@ -21,6 +21,7 @@ export function expandHomePath(path: string): string { /** Summary of actions taken during sync */ export interface SyncSummary { added: string[]; + updated: string[]; removed: string[]; unchanged: string[]; } @@ -57,11 +58,11 @@ export async function syncManagedDir( ): Promise { const summary: SyncSummary = { added: [], + updated: [], removed: [], unchanged: [], }; - // Check if managed dir already exists let currentEntries: string[] = []; let dirExists = false; try { @@ -71,12 +72,10 @@ export async function syncManagedDir( // Directory doesn't exist yet } - // Nothing desired and nothing exists — skip entirely, don't create empty dirs if (desiredItems.size === 0 && !dirExists) { return summary; } - // Resolve actual target names (handles file-based items with extensions) const resolvedItems: Array<{ name: string; targetName: string; sourcePath: string }> = []; for (const [name, sourcePath] of desiredItems) { const targetName = await resolveTargetName(name, sourcePath); @@ -85,7 +84,6 @@ export async function syncManagedDir( const targetNames = new Set(resolvedItems.map((item) => item.targetName)); - // Remove stale entries (not in desired set) for (const entry of currentEntries) { if (!targetNames.has(entry)) { await rm(join(managedDir, entry), { recursive: true, force: true }); @@ -93,7 +91,6 @@ export async function syncManagedDir( } } - // If nothing desired remains, clean up the empty managed dir if (desiredItems.size === 0) { try { const remaining = await readdir(managedDir); @@ -106,10 +103,8 @@ export async function syncManagedDir( return summary; } - // Ensure managed dir exists before installing await mkdir(managedDir, { recursive: true }); - // Install desired items for (const { name, targetName, sourcePath } of resolvedItems) { const targetPath = join(managedDir, targetName); @@ -118,22 +113,24 @@ export async function syncManagedDir( if (result === "created") { summary.added.push(name); } else if (result === "replaced") { - summary.added.push(name); + summary.updated.push(name); } else { summary.unchanged.push(name); } - } else { - // Copy mode — smart sync - try { - await lstat(targetPath); - // Exists — sync it - await smartSync(sourcePath, targetPath); + continue; + } + + try { + await lstat(targetPath); + const changed = await smartSync(sourcePath, targetPath); + if (changed) { + summary.updated.push(name); + } else { summary.unchanged.push(name); - } catch { - // Doesn't exist — initial copy - await smartSync(sourcePath, targetPath); - summary.added.push(name); } + } catch { + await smartSync(sourcePath, targetPath); + summary.added.push(name); } } diff --git a/src/integration.test.ts b/src/integration.test.ts index 0cff203..9745306 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -4,7 +4,7 @@ * Creates a temp git repo with skills and agents, then runs: * clone → discover → install → prune */ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { mkdtemp, mkdir, @@ -17,39 +17,67 @@ import { import { join } from "node:path"; import { tmpdir } from "node:os"; import { ensureRepo, getCacheDir } from "./cache/cache.ts"; +import { deriveCacheKey } from "./cache/url.ts"; import { discoverSkills, discoverAgents, filterItems } from "./discovery/discovery.ts"; import { syncManagedDir } from "./install/managed.ts"; import { cleanupLegacyManagedDirs } from "./install/migration.ts"; import { resetRegistry } from "./registry/registry.ts"; import { resetLogState, hasLoggedErrors, getLogPath } from "./log/logger.ts"; +import { saveProjectConfig } from "./config/project.ts"; +import type { GlobalConfig } from "./config/global.ts"; +import { runInstall } from "./commands/install.ts"; let tempDir: string; let repoDir: string; - -/** Cache keys used by tests — cleaned up in afterEach */ -const testCacheKeys = ["test-integration", "test-update-fail", "test-bad-ref"]; +let testCacheKeys: string[]; + +const GIT_ENV_VARS_TO_UNSET = [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_NAMESPACE", + "GIT_PREFIX", + "GIT_SUPER_PREFIX", +] as const; + +function gitTestEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const key of GIT_ENV_VARS_TO_UNSET) { + delete env[key]; + } + return env; +} async function runGit(args: string[], cwd: string): Promise { const proc = Bun.spawn(["git", ...args], { cwd, + env: gitTestEnv(), stdout: "pipe", stderr: "pipe", }); await proc.exited; } +function createCacheKey(prefix: string): string { + const key = `${prefix}-${Math.random().toString(36).slice(2, 10)}`; + testCacheKeys.push(key); + return key; +} + beforeEach(async () => { resetRegistry(); tempDir = await mkdtemp(join(tmpdir(), "agentdeps-integration-")); repoDir = join(tempDir, "test-repo"); + testCacheKeys = []; - // Create a local git repo with skills and agents await mkdir(repoDir, { recursive: true }); await runGit(["init", "-b", "main"], repoDir); await runGit(["config", "user.email", "test@test.com"], repoDir); await runGit(["config", "user.name", "Test"], repoDir); - // Add skills await mkdir(join(repoDir, "skills", "my-skill"), { recursive: true }); await writeFile( join(repoDir, "skills", "my-skill", "SKILL.md"), @@ -62,7 +90,6 @@ beforeEach(async () => { "# Another Skill" ); - // Add agents await mkdir(join(repoDir, "agents", "test-agent"), { recursive: true }); await writeFile( join(repoDir, "agents", "test-agent", "agent.md"), @@ -75,7 +102,6 @@ beforeEach(async () => { afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); - // Clean up cached repos to prevent cross-test contamination const cacheDir = getCacheDir(); for (const key of testCacheKeys) { await rm(join(cacheDir, key), { recursive: true, force: true }); @@ -83,29 +109,24 @@ afterEach(async () => { }); describe("full install flow", () => { - it("clones, discovers, installs, and prunes", async () => { - // 1. Clone the local repo + test.serial("clones, discovers, installs, and prunes", async () => { const cacheDir = join(tempDir, "cache"); await mkdir(cacheDir, { recursive: true }); - await ensureRepo(repoDir, "main", `test-integration`); - // The ensureRepo uses its own cache dir, but we can test discovery on the repoDir directly + await ensureRepo(repoDir, "main", createCacheKey("test-integration")); - // 2. Discover skills and agents const skills = await discoverSkills(repoDir); expect(skills.map((s) => s.name)).toEqual(["another-skill", "my-skill"]); const agents = await discoverAgents(repoDir); expect(agents.map((a) => a.name)).toEqual(["test-agent"]); - // 3. Filter (all) const skillResult = filterItems(skills, "*"); expect(skillResult.selected.map((s) => s.name)).toEqual(["another-skill", "my-skill"]); const agentResult = filterItems(agents, "*"); expect(agentResult.selected.map((a) => a.name)).toEqual(["test-agent"]); - // 4. Install to managed dir (pi now uses .agents/ paths) const managedSkillsDir = join(tempDir, "project", ".agents", "skills", "_agentdeps_managed"); const managedAgentsDir = join(tempDir, "project", ".agents", "agents", "_agentdeps_managed"); @@ -119,23 +140,25 @@ describe("full install flow", () => { const skillSummary = await syncManagedDir(managedSkillsDir, desiredSkills, "link"); expect(skillSummary.added).toEqual(["another-skill", "my-skill"]); + expect(skillSummary.updated).toEqual([]); const agentSummary = await syncManagedDir(managedAgentsDir, desiredAgents, "link"); expect(agentSummary.added).toEqual(["test-agent"]); + expect(agentSummary.updated).toEqual([]); - // Verify symlinks const skillEntries = await readdir(managedSkillsDir); expect(skillEntries.sort()).toEqual(["another-skill", "my-skill"]); const stat = await lstat(join(managedSkillsDir, "my-skill")); expect(stat.isSymbolicLink()).toBe(true); - // 5. Prune: remove one skill from desired set const prunedSkills = new Map([ ["my-skill", join(repoDir, "skills", "my-skill")], ]); const pruneSummary = await syncManagedDir(managedSkillsDir, prunedSkills, "link"); + expect(pruneSummary.added).toEqual([]); + expect(pruneSummary.updated).toEqual([]); expect(pruneSummary.removed).toEqual(["another-skill"]); expect(pruneSummary.unchanged).toEqual(["my-skill"]); @@ -143,56 +166,124 @@ describe("full install flow", () => { expect(afterPrune).toEqual(["my-skill"]); }); - it("fails when cache update fails (e.g. broken remote)", async () => { + test.serial("prints changed managed skill names after a repository update", async () => { + const config: GlobalConfig = { + clone_method: "https", + agents: ["pi"], + install_method: "link", + }; + const projectDir = join(tempDir, "project"); + const projectConfigPath = join(projectDir, "agents.yaml"); + const cacheKey = deriveCacheKey(repoDir, "main"); + testCacheKeys.push(cacheKey); + + const originalCwd = process.cwd(); + const originalXdgConfigHome = process.env["XDG_CONFIG_HOME"]; + const originalXdgStateHome = process.env["XDG_STATE_HOME"]; + process.env["XDG_CONFIG_HOME"] = join(tempDir, "xdg-config"); + process.env["XDG_STATE_HOME"] = join(tempDir, "xdg-state"); + await mkdir(projectDir, { recursive: true }); + await saveProjectConfig(projectConfigPath, { + dependencies: [ + { + repo: repoDir, + ref: "main", + skills: "*", + agents: false, + }, + ], + }); + + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + let output = ""; + try { + process.chdir(projectDir); + await runInstall(config); + + await writeFile( + join(repoDir, "skills", "my-skill", "SKILL.md"), + "# My Skill\nUpdated content" + ); + await rm(join(repoDir, "skills", "another-skill"), { + recursive: true, + force: true, + }); + await mkdir(join(repoDir, "skills", "new-skill"), { recursive: true }); + await writeFile( + join(repoDir, "skills", "new-skill", "SKILL.md"), + "# New Skill" + ); + await runGit(["add", "."], repoDir); + await runGit(["commit", "-m", "update skills"], repoDir); + + logSpy.mockClear(); + await runInstall(config); + output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join("\n"); + } finally { + process.chdir(originalCwd); + if (originalXdgConfigHome === undefined) { + delete process.env["XDG_CONFIG_HOME"]; + } else { + process.env["XDG_CONFIG_HOME"] = originalXdgConfigHome; + } + if (originalXdgStateHome === undefined) { + delete process.env["XDG_STATE_HOME"]; + } else { + process.env["XDG_STATE_HOME"] = originalXdgStateHome; + } + logSpy.mockRestore(); + } + + expect(output).toContain("skills added: new-skill"); + expect(output).toContain("skills updated: my-skill"); + expect(output).toContain("skills removed: another-skill"); + + const managedSkillsDir = join(projectDir, ".agents", "skills", "_agentdeps_managed"); + expect((await readdir(managedSkillsDir)).sort()).toEqual(["my-skill", "new-skill"]); + }); + + test.serial("fails when cache update fails (e.g. broken remote)", async () => { resetLogState(); - // 1. Clone successfully with the real repo - const result1 = await ensureRepo(repoDir, "main", "test-update-fail"); + const updateFailCacheKey = createCacheKey("test-update-fail"); + const result1 = await ensureRepo(repoDir, "main", updateFailCacheKey); expect(result1.success).toBe(true); - // 2. Break the origin remote so the next fetch will fail. - // updateRepo uses `git fetch origin` on the cached repo, - // so we point origin at a non-existent path. const cachedRepoPath = result1.path; await runGit(["remote", "set-url", "origin", "/nonexistent/path"], cachedRepoPath); - // 3. Call ensureRepo again with the same cacheKey. - // The cache dir exists → updateRepo → fetch fails → logError → returns failure. - const result2 = await ensureRepo(repoDir, "main", "test-update-fail"); + const result2 = await ensureRepo(repoDir, "main", updateFailCacheKey); - // Should fail — invalid refs and broken remotes must not silently succeed expect(result2.success).toBe(false); expect(result2.error).toBeDefined(); - // The failed update should have been logged expect(hasLoggedErrors()).toBe(true); const logContent = await readFile(getLogPath(), "utf-8"); expect(logContent).toContain("cache.update"); }); - it("fails when a non-existent ref is specified", async () => { - // Attempt to clone with a ref that doesn't exist - const result = await ensureRepo(repoDir, "nonexistent-branch", "test-bad-ref"); + test.serial("fails when a non-existent ref is specified", async () => { + const result = await ensureRepo(repoDir, "nonexistent-branch", createCacheKey("test-bad-ref")); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); }); - describe("legacy path migration", () => { - it("cleans up legacy managed dirs and installs to .agents/", async () => { + test.serial("cleans up legacy managed dirs and installs to .agents/", async () => { const projectDir = join(tempDir, "project"); - // Simulate existing legacy managed dirs at old Pi paths const legacySkillsManaged = join(projectDir, ".pi/skills/_agentdeps_managed/my-skill"); const legacyAgentsManaged = join(projectDir, ".pi/agents/_agentdeps_managed/test-agent"); await mkdir(legacySkillsManaged, { recursive: true }); await mkdir(legacyAgentsManaged, { recursive: true }); await writeFile(join(legacySkillsManaged, "SKILL.md"), "old"); - // Run migration from the project directory const originalCwd = process.cwd(); process.chdir(projectDir); try { @@ -201,14 +292,12 @@ describe("legacy path migration", () => { process.chdir(originalCwd); } - // Legacy managed dirs should be gone const piSkillsEntries = await readdir(join(projectDir, ".pi/skills")); expect(piSkillsEntries).not.toContain("_agentdeps_managed"); const piAgentsEntries = await readdir(join(projectDir, ".pi/agents")); expect(piAgentsEntries).not.toContain("_agentdeps_managed"); - // Now install skills to the new .agents/ path const skills = await discoverSkills(repoDir); const skillResult = filterItems(skills, "*"); @@ -219,9 +308,9 @@ describe("legacy path migration", () => { const summary = await syncManagedDir(newManagedDir, desiredSkills, "link"); expect(summary.added).toEqual(["another-skill", "my-skill"]); + expect(summary.updated).toEqual([]); - // Verify new location has the skills const newEntries = await readdir(newManagedDir); expect(newEntries.sort()).toEqual(["another-skill", "my-skill"]); }); -}); \ No newline at end of file +}); diff --git a/src/log/logger.test.ts b/src/log/logger.test.ts index 3944211..b2c705b 100644 --- a/src/log/logger.test.ts +++ b/src/log/logger.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdtemp, rm, readFile } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { tmpdir, platform } from "node:os"; // We need to set XDG_STATE_HOME *before* importing the logger so getLogDir() // picks up the test directory. The logger caches logDirEnsured, so we reset. @@ -27,12 +27,16 @@ afterEach(async () => { }); describe("logger", () => { - it("getLogDir uses XDG_STATE_HOME when set", async () => { - // Re-import to get fresh module state + it("resolves the platform-appropriate log directory", async () => { const { getLogDir } = await import("./logger.ts"); const dir = getLogDir(); - expect(dir).toContain(tempDir); - expect(dir).toContain("agentdeps"); + + if (platform() === "darwin") { + expect(dir).toContain("Library/Logs/agentdeps"); + } else { + expect(dir).toContain(tempDir); + expect(dir).toContain("agentdeps"); + } }); it("getLogPath returns a file inside the log dir", async () => { From e2bad8771f2b6ebeb7f3b59db664ce311180a6e7 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 27 Mar 2026 16:43:08 +0100 Subject: [PATCH 2/2] chore: bump version to 0.5.1 --- CHANGELOG.md | 1 + package.json | 2 +- src/version.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 409acc6..7c0183e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +## 0.5.1 ### Features - **Verbose install update summaries** — `agentdeps install` now reports which managed skills and agents were added, updated, or removed when a dependency repository changes, making registry updates much easier to verify. diff --git a/package.json b/package.json index e23f90e..59c6d8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentdeps", - "version": "0.5.0", + "version": "0.5.1", "description": "Declarative dependency manager for AI coding agent skills and subagents", "module": "src/index.ts", "type": "module", diff --git a/src/version.ts b/src/version.ts index c5aad5d..186df05 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** Package version — kept in sync with package.json */ -export const version = "0.5.0"; +export const version = "0.5.1";