diff --git a/.changeset/calm-clips-rewind.md b/.changeset/calm-clips-rewind.md new file mode 100644 index 0000000000..55280ed2dd --- /dev/null +++ b/.changeset/calm-clips-rewind.md @@ -0,0 +1,6 @@ +--- +"@agent-native/skills": patch +"@agent-native/core": patch +--- + +Install Rewind through Agent Native's complete local Screen Memory setup instead of treating it as a plain public skill, reject disconnected installs that disable its required MCP connection, and guide first-time users through consent-gated Clips Desktop setup. diff --git a/.github/workflows/sync-public-skills.yml b/.github/workflows/sync-public-skills.yml index 848f5451ce..ed45fc968f 100644 --- a/.github/workflows/sync-public-skills.yml +++ b/.github/workflows/sync-public-skills.yml @@ -10,6 +10,7 @@ on: - "templates/plan/.agents/skills/visual-plan/**" - "templates/plan/.agents/skills/visual-recap/**" - "packages/core/src/cli/skills.ts" + - "packages/core/src/cli/skills-content/rewind-skill.ts" - ".github/workflows/sync-public-skills.yml" workflow_dispatch: @@ -21,7 +22,7 @@ concurrency: jobs: dispatch: - name: Ask BuilderIO/skills to sync visual skills + name: Ask BuilderIO/skills to sync exported skills runs-on: ubuntu-latest steps: - name: Generate app token @@ -43,3 +44,10 @@ jobs: -f "client_payload[sourceRepository]=${{ github.repository }}" \ -f "client_payload[sourceSha]=${{ github.sha }}" \ -f "client_payload[sourceRunUrl]=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + gh api repos/BuilderIO/skills/dispatches \ + --method POST \ + -f event_type=agent-native-public-skills-updated \ + -f "client_payload[sourceRepository]=${{ github.repository }}" \ + -f "client_payload[sourceSha]=${{ github.sha }}" \ + -f "client_payload[sourceRunUrl]=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/packages/core/src/cli/index.ts b/packages/core/src/cli/index.ts index b35d629fbb..c4ff45c6bd 100644 --- a/packages/core/src/cli/index.ts +++ b/packages/core/src/cli/index.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from "url"; import * as Sentry from "@sentry/node"; import { resolveDeployPostBuildInvocation } from "./deploy-build.js"; +import { shouldTrackCliRun } from "./telemetry-routing.js"; import { createCliTelemetry } from "./telemetry.js"; // Resolve version once at module scope — used by both --version and --help @@ -604,7 +605,7 @@ function runBuildStep( }); } -trackCli("cli.run"); +if (shouldTrackCliRun(command, args)) trackCli("cli.run"); switch (command) { case "dev": { diff --git a/packages/core/src/cli/skills-content/rewind-skill.ts b/packages/core/src/cli/skills-content/rewind-skill.ts index 3bde26f64c..2c030feeee 100644 --- a/packages/core/src/cli/skills-content/rewind-skill.ts +++ b/packages/core/src/cli/skills-content/rewind-skill.ts @@ -13,6 +13,38 @@ metadata: Use Clips Rewind as local screen memory. Start broad enough to find the right moment, then read only the smallest relevant range. +## First-Run Setup + +Call \`screen_memory_status\` before assuming Rewind is ready. If the tool is +unavailable, do not stop at a missing-tool error and do not silently install +anything: + +1. Explain that Rewind requires the signed Clips Desktop app on macOS plus a + local agent connection. +2. Ask whether Clips is installed, or use a non-mutating native application + lookup when the host supports one. Do not inspect Clips' app-data folders. + If Clips is absent or its presence cannot be confirmed, ask permission + before opening or downloading anything: “Clips Desktop is required for + Rewind. Would you like me to open the official installer and guide you + through setup?” +3. Only after affirmative permission, open + \`https://clips.agent-native.com/download\` in the user's browser. Complete + native installation steps only when the current host can safely do so and + the user explicitly permitted them. Never bypass Gatekeeper, accept macOS + permission prompts, or enable screen/audio capture on the user's behalf. +4. If native UI control is unavailable, return the download link and wait for + the user to confirm that Clips is installed and running. +5. Ask the user to enable Rewind in the Clips tray and choose the intended + capture mode. Then configure the local connection with: + + \`npx -y @agent-native/core@latest skills add rewind --client --scope user --yes\` + + Replace \`\` with the current compatible host: \`codex\`, + \`claude-code\`, \`cursor\`, \`opencode\`, \`github-copilot\`, or \`cowork\`. +6. Restart the host only if it cannot reload MCP servers in place, then retry + \`screen_memory_status\`. Do not claim setup succeeded until it reports + Rewind enabled and unpaused. + ## Retrieval Flow 1. Call \`screen_memory_status\` first. If the newest segment is still open, @@ -41,12 +73,12 @@ moment, then read only the smallest relevant range. bypass the Screen Memory MCP broker. - Do not upload frames returned by local Screen Memory tools. - Treat foreground apps and chapter labels as evidence, not proof of intent. -- If the Screen Memory MCP is missing, explain that the one-time setup needs to - be repaired with: +- If Clips is installed and Rewind is enabled but the Screen Memory MCP is + missing, explain that only the agent connection needs repair with: \`npx -y @agent-native/core@latest skills add rewind --client --scope user --yes\` - Replace \`\` with the current compatible host: \`codex\`, - \`claude-code\`, \`cursor\`, \`opencode\`, \`github-copilot\`, or \`cowork\`. Ask the user to restart the host if it cannot reload MCP servers in place. +- Never conflate installing Clips Desktop with installing the skill/MCP + connection, and never claim either succeeded without direct evidence. `; diff --git a/packages/core/src/cli/skills.spec.ts b/packages/core/src/cli/skills.spec.ts index f5514a416d..625bbd0b40 100644 --- a/packages/core/src/cli/skills.spec.ts +++ b/packages/core/src/cli/skills.spec.ts @@ -170,7 +170,13 @@ describe("agent-native skills", () => { path.join(codexHome, "skills", "rewind", "SKILL.md"), "utf-8", ), - ).toContain("screen_memory_search_chapters"); + ).toContain("ask permission\n before opening or downloading anything"); + expect( + fs.readFileSync( + path.join(codexHome, "skills", "rewind", "SKILL.md"), + "utf-8", + ), + ).toContain("https://clips.agent-native.com/download"); const config = fs.readFileSync( path.join(codexHome, "config.toml"), "utf-8", @@ -191,12 +197,167 @@ describe("agent-native skills", () => { } }); - it("can install only the Rewind instructions without touching MCP config", async () => { + it("rejects Rewind installs that disable the local Screen Memory MCP", async () => { + const root = tmpDir(); + const codexHome = path.join(root, "codex-home"); + fs.mkdirSync(codexHome, { recursive: true }); + const previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + + try { + await expect( + addAgentNativeSkill( + parseSkillsArgs([ + "add", + "rewind", + "--client", + "codex", + "--scope", + "user", + "--no-mcp", + "--yes", + ]), + { baseDir: root }, + ), + ).rejects.toThrow("cannot be installed with --no-mcp"); + + expect( + fs.existsSync(path.join(codexHome, "skills", "rewind", "SKILL.md")), + ).toBe(false); + expect(fs.existsSync(path.join(codexHome, "config.toml"))).toBe(false); + } finally { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } + }); + + it("directs missing-store installs to Clips without claiming native setup", async () => { const root = tmpDir(); + const home = path.join(root, "home"); const codexHome = path.join(root, "codex-home"); + fs.mkdirSync(home, { recursive: true }); fs.mkdirSync(codexHome, { recursive: true }); + const previousHome = process.env.HOME; const previousCodexHome = process.env.CODEX_HOME; + const previousStore = process.env.CLIPS_SCREEN_MEMORY_DIR; + const previousLegacyStore = process.env.AGENT_NATIVE_SCREEN_MEMORY_DIR; + process.env.HOME = home; process.env.CODEX_HOME = codexHome; + delete process.env.CLIPS_SCREEN_MEMORY_DIR; + delete process.env.AGENT_NATIVE_SCREEN_MEMORY_DIR; + + try { + await expect( + addAgentNativeSkill( + parseSkillsArgs([ + "add", + "rewind", + "--client", + "codex", + "--scope", + "user", + "--yes", + ]), + { baseDir: root }, + ), + ).rejects.toThrow( + /https:\/\/clips\.agent-native\.com\/download.*was not installed or enabled automatically/, + ); + + expect( + fs.existsSync(path.join(codexHome, "skills", "rewind", "SKILL.md")), + ).toBe(false); + expect(fs.existsSync(path.join(codexHome, "config.toml"))).toBe(false); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousStore === undefined) + delete process.env.CLIPS_SCREEN_MEMORY_DIR; + else process.env.CLIPS_SCREEN_MEMORY_DIR = previousStore; + if (previousLegacyStore === undefined) + delete process.env.AGENT_NATIVE_SCREEN_MEMORY_DIR; + else process.env.AGENT_NATIVE_SCREEN_MEMORY_DIR = previousLegacyStore; + } + }); + + it("rejects a missing Rewind store before telemetry or user config writes", async () => { + const root = tmpDir(); + const home = path.join(root, "home"); + const codexHome = path.join(root, "codex-home"); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true }); + const previousHome = process.env.HOME; + const previousCodexHome = process.env.CODEX_HOME; + const previousStore = process.env.CLIPS_SCREEN_MEMORY_DIR; + const previousLegacyStore = process.env.AGENT_NATIVE_SCREEN_MEMORY_DIR; + process.env.HOME = home; + process.env.CODEX_HOME = codexHome; + delete process.env.CLIPS_SCREEN_MEMORY_DIR; + delete process.env.AGENT_NATIVE_SCREEN_MEMORY_DIR; + const telemetry = { + track: vi.fn(), + captureException: vi.fn(), + flush: vi.fn(async () => undefined), + }; + + try { + await expect( + runSkills( + ["add", "rewind", "--client", "codex", "--scope", "user", "--yes"], + { baseDir: root, telemetry }, + ), + ).rejects.toThrow( + /https:\/\/clips\.agent-native\.com\/download.*was not installed or enabled automatically/, + ); + await expect( + runSkills( + [ + "add", + "--skill", + "rewind", + "--client", + "codex", + "--scope", + "user", + "--yes", + ], + { baseDir: root, telemetry }, + ), + ).rejects.toThrow( + /https:\/\/clips\.agent-native\.com\/download.*was not installed or enabled automatically/, + ); + + expect(telemetry.track).not.toHaveBeenCalled(); + expect(telemetry.captureException).not.toHaveBeenCalled(); + expect(telemetry.flush).not.toHaveBeenCalled(); + expect(fs.readdirSync(home)).toEqual([]); + expect(fs.readdirSync(codexHome)).toEqual([]); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousStore === undefined) + delete process.env.CLIPS_SCREEN_MEMORY_DIR; + else process.env.CLIPS_SCREEN_MEMORY_DIR = previousStore; + if (previousLegacyStore === undefined) + delete process.env.AGENT_NATIVE_SCREEN_MEMORY_DIR; + else process.env.AGENT_NATIVE_SCREEN_MEMORY_DIR = previousLegacyStore; + } + }); + + it("installs Rewind into the shared user skill directory for Cursor", async () => { + const root = tmpDir(); + const home = path.join(root, "home"); + const store = path.join(root, "screen-memory"); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(store, { recursive: true }); + const previousHome = process.env.HOME; + const previousStore = process.env.CLIPS_SCREEN_MEMORY_DIR; + process.env.HOME = home; + process.env.CLIPS_SCREEN_MEMORY_DIR = store; try { const result = await addAgentNativeSkill( @@ -204,27 +365,33 @@ describe("agent-native skills", () => { "add", "rewind", "--client", - "codex", + "cursor", "--scope", "user", - "--no-mcp", "--yes", ]), { baseDir: root }, ); - expect(result.mcpClients).toEqual([]); + expect(result.skillsAgents).toEqual(["cursor"]); expect( - fs.existsSync(path.join(codexHome, "skills", "rewind", "SKILL.md")), + fs.existsSync( + path.join(home, ".agents", "skills", "rewind", "SKILL.md"), + ), ).toBe(true); - expect(fs.existsSync(path.join(codexHome, "config.toml"))).toBe(false); + expect( + fs.readFileSync(path.join(home, ".cursor", "mcp.json"), "utf-8"), + ).toContain("clips-screen-memory"); } finally { - if (previousCodexHome === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousCodexHome; + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousStore === undefined) + delete process.env.CLIPS_SCREEN_MEMORY_DIR; + else process.env.CLIPS_SCREEN_MEMORY_DIR = previousStore; } }); - it("installs Rewind into the shared user skill directory for Cursor", async () => { + it("installs Rewind instructions and local MCP config for Claude Code", async () => { const root = tmpDir(); const home = path.join(root, "home"); const store = path.join(root, "screen-memory"); @@ -241,7 +408,7 @@ describe("agent-native skills", () => { "add", "rewind", "--client", - "cursor", + "claude-code", "--scope", "user", "--yes", @@ -249,14 +416,14 @@ describe("agent-native skills", () => { { baseDir: root }, ); - expect(result.skillsAgents).toEqual(["cursor"]); + expect(result.mcpClients).toEqual(["claude-code"]); expect( fs.existsSync( - path.join(home, ".agents", "skills", "rewind", "SKILL.md"), + path.join(home, ".claude", "skills", "rewind", "SKILL.md"), ), ).toBe(true); expect( - fs.readFileSync(path.join(home, ".cursor", "mcp.json"), "utf-8"), + fs.readFileSync(path.join(home, ".claude.json"), "utf-8"), ).toContain("clips-screen-memory"); } finally { if (previousHome === undefined) delete process.env.HOME; @@ -267,6 +434,81 @@ describe("agent-native skills", () => { } }); + it.each(["codex", "claude-code"] as const)( + "restores existing %s instructions and config when MCP setup fails", + async (client) => { + const root = tmpDir(); + const home = path.join(root, "home"); + const codexHome = path.join(root, "codex-home"); + const store = path.join(root, "screen-memory"); + const skillDir = + client === "codex" + ? path.join(codexHome, "skills", "rewind") + : path.join(home, ".claude", "skills", "rewind"); + const configPath = + client === "codex" + ? path.join(codexHome, "config.toml") + : path.join(home, ".claude.json"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(store, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "original skill\n"); + fs.writeFileSync(configPath, "original config\n"); + const previousHome = process.env.HOME; + const previousCodexHome = process.env.CODEX_HOME; + const previousStore = process.env.CLIPS_SCREEN_MEMORY_DIR; + process.env.HOME = home; + process.env.CODEX_HOME = codexHome; + process.env.CLIPS_SCREEN_MEMORY_DIR = store; + const telemetry = { + track: vi.fn(), + captureException: vi.fn(), + flush: vi.fn(async () => undefined), + }; + + try { + await expect( + addAgentNativeSkill( + parseSkillsArgs([ + "add", + "rewind", + "--client", + client, + "--scope", + "user", + "--yes", + ]), + { + baseDir: root, + telemetry, + installScreenMemory: () => { + fs.writeFileSync(configPath, "partial config\n"); + throw new Error("simulated MCP write failure"); + }, + }, + ), + ).rejects.toThrow("simulated MCP write failure"); + + expect(fs.readFileSync(configPath, "utf-8")).toBe("original config\n"); + expect(fs.readFileSync(path.join(skillDir, "SKILL.md"), "utf-8")).toBe( + "original skill\n", + ); + expect(telemetry.track).not.toHaveBeenCalledWith( + "skills_cli install completed", + expect.anything(), + ); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousStore === undefined) + delete process.env.CLIPS_SCREEN_MEMORY_DIR; + else process.env.CLIPS_SCREEN_MEMORY_DIR = previousStore; + } + }, + ); + it("dry-runs Rewind setup without requiring an active local store", async () => { const result = await addAgentNativeSkill( parseSkillsArgs([ diff --git a/packages/core/src/cli/skills.ts b/packages/core/src/cli/skills.ts index a456ddbb34..d4c2aec83e 100644 --- a/packages/core/src/cli/skills.ts +++ b/packages/core/src/cli/skills.ts @@ -32,7 +32,7 @@ import { CONTEXT_XRAY_SKILL_MD, installLocalContextXray, } from "./context-xray-local.js"; -import { CLIENTS, type ClientId } from "./mcp-config-writers.js"; +import { CLIENTS, configPathFor, type ClientId } from "./mcp-config-writers.js"; import { installScreenMemoryForClient, resolveScreenMemoryStoreDir, @@ -800,6 +800,7 @@ export interface RunSkillsOptions { * browser/device OAuth round-trip. */ runConnect?: (args: string[]) => Promise; + installScreenMemory?: typeof installScreenMemoryForClient; /** * Best-effort install-funnel telemetry. Created once per `runSkills` run and * threaded through resolution/install/connect so each `track` is fire-and- @@ -850,6 +851,29 @@ function isKnownSkill(value: string | undefined): boolean { return Boolean(normalizeKnownSkillTarget(value)); } +const REWIND_MISSING_STORE_ERROR = + "No local Clips Screen Memory store was found. Clips Desktop is required for Rewind. Download and launch the signed app from https://clips.agent-native.com/download, turn Rewind on, then run the setup again. Clips Desktop was not installed or enabled automatically."; + +function preflightRewindStore(parsed: ParsedSkillsArgs): string | undefined { + if ( + parsed.command !== "add" || + parsed.dryRun || + !parsed.mcp || + parsed.mcpUrl + ) { + return undefined; + } + const explicitlyTargetsRewind = + normalizeKnownSkillTarget(parsed.target ?? "assets") === "rewind" || + parsed.plainSkillNames?.some( + (skillName) => normalizeKnownSkillTarget(skillName) === "rewind", + ); + if (!explicitlyTargetsRewind) return undefined; + const screenMemoryDir = resolveScreenMemoryStoreDir(); + if (!screenMemoryDir) throw new Error(REWIND_MISSING_STORE_ERROR); + return screenMemoryDir; +} + function isLocalOnlyBuiltInSkill( entry: (typeof BUILT_IN_APP_SKILLS)[BuiltInAppSkillId] | null | undefined, ): boolean { @@ -1382,7 +1406,7 @@ $ARGUMENTS * there is no need to shell out to the separate @agent-native/skills installer * (which would have to be published to npm first). Returns the written folders. */ -function installBuiltInInstructions(input: { +type BuiltInInstructionInstallInput = { appSkillId: BuiltInAppSkillId; onlySkillNames?: string[]; skillsAgents: string[]; @@ -1391,7 +1415,11 @@ function installBuiltInInstructions(input: { dryRun?: boolean; planMode?: PlanInstallMode; mcpUrl?: string; -}): string[] { +}; + +function builtInInstructionPaths( + input: BuiltInInstructionInstallInput, +): string[] { const bundles = Object.values( skillFilesForBuiltIn(input.appSkillId, { planMode: input.planMode, @@ -1411,15 +1439,10 @@ function installBuiltInInstructions(input: { ); for (const bundle of bundles) { const dir = path.join(root, bundle.skillName); - if (!input.dryRun) writeSkillFolder(dir, bundle); written.push(dir); const command = slashCommandForBuiltInSkill(bundle.skillName); if (command) { const commandPath = path.join(commandsRoot, `${bundle.skillName}.md`); - if (!input.dryRun) { - fs.mkdirSync(path.dirname(commandPath), { recursive: true }); - fs.writeFileSync(commandPath, command, "utf-8"); - } written.push(commandPath); } } @@ -1427,6 +1450,87 @@ function installBuiltInInstructions(input: { return written; } +function installBuiltInInstructions( + input: BuiltInInstructionInstallInput, +): string[] { + const bundles = Object.values( + skillFilesForBuiltIn(input.appSkillId, { + planMode: input.planMode, + mcpUrl: input.mcpUrl, + }), + ).filter( + (bundle) => + !input.onlySkillNames || input.onlySkillNames.includes(bundle.skillName), + ); + const written = builtInInstructionPaths(input); + if (input.dryRun) return written; + + for (const agent of input.skillsAgents) { + const root = builtInSkillsRootForAgent(agent, input.scope, input.baseDir); + const commandsRoot = builtInCommandsRootForAgent( + agent, + input.scope, + input.baseDir, + ); + for (const bundle of bundles) { + writeSkillFolder(path.join(root, bundle.skillName), bundle); + const command = slashCommandForBuiltInSkill(bundle.skillName); + if (command) { + const commandPath = path.join(commandsRoot, `${bundle.skillName}.md`); + fs.mkdirSync(path.dirname(commandPath), { recursive: true }); + fs.writeFileSync(commandPath, command, "utf-8"); + } + } + } + return written; +} + +interface InstallPathSnapshot { + target: string; + backup: string; + existed: boolean; +} + +function snapshotInstallPaths( + targets: string[], + backupRoot: string, +): InstallPathSnapshot[] { + return [...new Set(targets)].map((target, index) => { + const backup = path.join(backupRoot, `snapshot-${index}`); + const existed = fs.existsSync(target); + if (existed) fs.cpSync(target, backup, { recursive: true }); + return { target, backup, existed }; + }); +} + +function removeEmptyParents(start: string, boundary: string): void { + let current = path.resolve(start); + const stop = path.resolve(boundary); + while (current !== stop && current.startsWith(`${stop}${path.sep}`)) { + if (!fs.existsSync(current) || fs.readdirSync(current).length > 0) return; + fs.rmdirSync(current); + current = path.dirname(current); + } +} + +function restoreInstallPaths( + snapshots: InstallPathSnapshot[], + boundary: string, +): void { + for (const snapshot of snapshots.toReversed()) { + fs.rmSync(snapshot.target, { recursive: true, force: true }); + if (snapshot.existed) { + fs.mkdirSync(path.dirname(snapshot.target), { recursive: true }); + fs.cpSync(snapshot.backup, snapshot.target, { recursive: true }); + } + } + for (const snapshot of snapshots) { + if (!snapshot.existed) { + removeEmptyParents(path.dirname(snapshot.target), boundary); + } + } +} + function listSkillFolderFiles(dir: string): Record { const out: Record = {}; const walk = (current: string, prefix = "") => { @@ -3376,6 +3480,11 @@ export async function addAgentNativeSkill( const knownBuiltIn = knownTarget ? BUILT_IN_APP_SKILLS[knownTarget] : null; const installsScreenMemoryMcp = isScreenMemoryMcpBuiltInSkill(knownBuiltIn); const baseDir = options.baseDir ?? process.cwd(); + if (installsScreenMemoryMcp && !parsed.mcp) { + throw new Error( + "Rewind requires the local Clips Screen Memory MCP and cannot be installed with --no-mcp.", + ); + } if (installsScreenMemoryMcp && parsed.mcpUrl) { throw new Error( "Rewind uses the local Clips Screen Memory MCP and does not accept --mcp-url.", @@ -3537,6 +3646,7 @@ export async function addAgentNativeSkill( } } const commands: string[] = []; + const screenMemoryDir = preflightRewindStore(parsed); const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "an-skills-add-")); let instructionSource: string | undefined; let instructionsWritten: string[] | undefined; @@ -3544,6 +3654,36 @@ export async function addAgentNativeSkill( let connectCommand: string | undefined; let registeredMcpClients: ClientId[] = shouldRegisterMcp ? mcpClients : []; let localManifestPath: string | undefined; + const builtInInstructionInput: BuiltInInstructionInstallInput | undefined = + knownTarget + ? { + appSkillId: knownTarget, + onlySkillNames, + skillsAgents, + scope: parsed.scope as "project" | "user", + baseDir, + dryRun: parsed.dryRun, + planMode, + mcpUrl: installTarget.loaded.manifest.hosted.mcpUrl, + } + : undefined; + const rewindSnapshots = installsScreenMemoryMcp + ? snapshotInstallPaths( + [ + ...(parsed.instructions && builtInInstructionInput + ? builtInInstructionPaths(builtInInstructionInput) + : []), + ...(shouldRegisterMcp + ? mcpClients.map((client) => + configPathFor(client, baseDir, parsed.scope), + ) + : []), + ], + tmpRoot, + ) + : undefined; + const rollbackBoundary = + parsed.scope === "user" ? os.homedir() : path.resolve(baseDir); try { if (parsed.instructions) { @@ -3553,21 +3693,14 @@ export async function addAgentNativeSkill( "Skill instructions use shared .agents for Codex, Pi, Cursor, OpenCode, Copilot, and similar agents, or Claude Code's native files. Use an MCP-capable client or omit --instructions-only.", ); } - } else if (knownTarget) { + } else if (knownTarget && builtInInstructionInput) { // Built-in skills ship their instructions inside this package, so copy // the skill folders straight into each client's skills directory. This // avoids shelling out to the separate @agent-native/skills installer // (which would need to be published to npm to run via npx). - instructionsWritten = installBuiltInInstructions({ - appSkillId: knownTarget, - onlySkillNames, - skillsAgents, - scope: parsed.scope as "project" | "user", - baseDir, - dryRun: parsed.dryRun, - planMode, - mcpUrl: installTarget.loaded.manifest.hosted.mcpUrl, - }); + instructionsWritten = installBuiltInInstructions( + builtInInstructionInput, + ); instructionSource = instructionsWritten[0]; commands.push(...instructionsWritten.map((dir) => `write ${dir}`)); } else { @@ -3611,29 +3744,24 @@ export async function addAgentNativeSkill( commands.push(`write ${localManifestPath}`); } - // Skill instructions are now on disk (built-in folders copied or external - // pack materialized) — record the install before MCP registration/connect. - options.telemetry?.track("skills_cli install completed", { - skills: installTarget.skillNames.join(","), - clients: clients.join(","), - scope: parsed.scope, - dryRun: Boolean(parsed.dryRun), - }); + // Rewind reports completion only after both local writes succeed. + if (!installsScreenMemoryMcp) { + options.telemetry?.track("skills_cli install completed", { + skills: installTarget.skillNames.join(","), + clients: clients.join(","), + scope: parsed.scope, + dryRun: Boolean(parsed.dryRun), + }); + } if (shouldRegisterMcp && installsScreenMemoryMcp) { - const screenMemoryDir = resolveScreenMemoryStoreDir(); - if (!screenMemoryDir) { - throw new Error( - "No local Clips Screen Memory store was found. Turn Rewind on in Clips, then run the setup again.", - ); - } registeredMcpClients = mcpClients.map((client) => { commands.push( `npx @agent-native/core@latest mcp install-screen-memory --client ${client} --scope ${parsed.scope}`, ); - installScreenMemoryForClient( + (options.installScreenMemory ?? installScreenMemoryForClient)( client, - screenMemoryDir, + screenMemoryDir!, baseDir, parsed.scope, ); @@ -3642,6 +3770,12 @@ export async function addAgentNativeSkill( options.telemetry?.track("skills_cli mcp registered", { skills: installTarget.skillNames.join(","), }); + options.telemetry?.track("skills_cli install completed", { + skills: installTarget.skillNames.join(","), + clients: clients.join(","), + scope: parsed.scope, + dryRun: false, + }); } else if (shouldRegisterMcp) { commands.push( `npx @agent-native/core@latest app-skill ensure --manifest ${installTarget.loaded.file} --client ${parsed.client} --scope ${parsed.scope} --yes`, @@ -3769,6 +3903,18 @@ export async function addAgentNativeSkill( githubActionExisted, githubActionSuggestedCommand, }; + } catch (error) { + if (rewindSnapshots) { + try { + restoreInstallPaths(rewindSnapshots, rollbackBoundary); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + "Rewind setup failed and its partial installation could not be fully rolled back.", + ); + } + } + throw error; } finally { fs.rmSync(tmpRoot, { recursive: true, force: true }); installTarget.cleanup?.(); @@ -4054,6 +4200,7 @@ export async function runSkills( if (parsed.baseDir) { options = { ...options, baseDir: path.resolve(parsed.baseDir) }; } + preflightRewindStore(parsed); const clackForLog = parsed.printJson ? undefined : await import("@clack/prompts"); diff --git a/packages/core/src/cli/telemetry-routing.spec.ts b/packages/core/src/cli/telemetry-routing.spec.ts new file mode 100644 index 0000000000..461ae1e9a3 --- /dev/null +++ b/packages/core/src/cli/telemetry-routing.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { shouldTrackCliRun } from "./telemetry-routing.js"; + +describe("shouldTrackCliRun", () => { + it.each([ + ["skills", ["add", "rewind", "--yes"]], + ["skills", ["add", "--skill", "rewind", "--yes"]], + ["skills", ["add", "--skill=rewind", "--yes"]], + ])("defers telemetry for explicit Rewind installs", (command, args) => { + expect(shouldTrackCliRun(command, args)).toBe(false); + }); + + it("tracks other CLI invocations immediately", () => { + expect(shouldTrackCliRun("skills", ["add", "visual-plan"])).toBe(true); + expect(shouldTrackCliRun("create", ["my-app"])).toBe(true); + }); +}); diff --git a/packages/core/src/cli/telemetry-routing.ts b/packages/core/src/cli/telemetry-routing.ts new file mode 100644 index 0000000000..e4ced0b322 --- /dev/null +++ b/packages/core/src/cli/telemetry-routing.ts @@ -0,0 +1,11 @@ +export function shouldTrackCliRun(command: string | undefined, args: string[]) { + if (command !== "skills" || args[0] !== "add") return true; + + const explicitlyTargetsRewind = args.some( + (arg, index) => + arg === "rewind" || + arg === "--skill=rewind" || + (arg === "--skill" && args[index + 1] === "rewind"), + ); + return !explicitlyTargetsRewind; +} diff --git a/packages/core/src/cli/telemetry.ts b/packages/core/src/cli/telemetry.ts index ff4d35f139..66a034c71b 100644 --- a/packages/core/src/cli/telemetry.ts +++ b/packages/core/src/cli/telemetry.ts @@ -198,7 +198,7 @@ export function createCliTelemetry(options: CliTelemetryOptions): CliTelemetry { const publicKey = resolvePublicKey(); const disabled = telemetryDisabled() || !publicKey; const endpoint = resolveEndpoint(); - const installId = disabled ? "" : resolveInstallId(); + let installId: string | undefined = disabled ? "" : undefined; const runId = crypto.randomUUID(); const inFlight = new Set>(); @@ -211,18 +211,18 @@ export function createCliTelemetry(options: CliTelemetryOptions): CliTelemetry { ci: process.env.CI === "true", interactive: options.interactive, runId, - installId, }; function track(event: string, properties?: Record): void { if (disabled) return; + installId ??= resolveInstallId(); const body = JSON.stringify({ publicKey, event, anonymousId: installId, sessionId: runId, timestamp: new Date().toISOString(), - properties: { ...base, ...properties }, + properties: { ...base, installId, ...properties }, }); const promise = fetch(endpoint, { method: "POST", diff --git a/packages/skills/src/index.spec.ts b/packages/skills/src/index.spec.ts index 8f119144f0..c9c6a3cf9f 100644 --- a/packages/skills/src/index.spec.ts +++ b/packages/skills/src/index.spec.ts @@ -746,6 +746,84 @@ describe("@agent-native/skills", () => { ); }); + it("delegates Rewind installs to the core local Screen Memory flow", async () => { + const project = tmpDir(); + const previousDirect = process.env.AGENT_NATIVE_SKILLS_DIRECT; + delete process.env.AGENT_NATIVE_SKILLS_DIRECT; + + try { + await runSkillsCli( + [ + "add", + "--skill", + "rewind", + "--client", + "codex", + "--scope", + "user", + "--yes", + "--json", + ], + { baseDir: project, isInteractive: () => false }, + ); + } finally { + if (previousDirect === undefined) + delete process.env.AGENT_NATIVE_SKILLS_DIRECT; + else process.env.AGENT_NATIVE_SKILLS_DIRECT = previousDirect; + } + + expect(runCoreSkills).toHaveBeenCalledWith( + [ + "add", + "rewind", + "--client", + "codex", + "--scope", + "user", + "--yes", + "--json", + ], + expect.objectContaining({ + baseDir: project, + catalogMode: "all", + isInteractive: expect.any(Function), + publicSkillEntries: [], + publicSkillSource: "BuilderIO/skills", + }), + ); + }); + + it("surfaces Core's rejection when Rewind MCP setup is disabled", async () => { + const project = tmpDir(); + vi.mocked(runCoreSkills).mockRejectedValueOnce( + new Error( + "Rewind requires the local Clips Screen Memory MCP and cannot be installed with --no-mcp.", + ), + ); + + await expect( + runSkillsCli( + [ + "add", + "--skill", + "rewind", + "--client", + "codex", + "--scope", + "user", + "--no-mcp", + "--yes", + ], + { baseDir: project, isInteractive: () => false }, + ), + ).rejects.toThrow("cannot be installed with --no-mcp"); + + expect(runCoreSkills).toHaveBeenCalledWith( + expect.arrayContaining(["rewind", "--no-mcp"]), + expect.objectContaining({ baseDir: project }), + ); + }); + it("delegates content local-files installs to agent-native core", async () => { const project = tmpDir(); const previousDirect = process.env.AGENT_NATIVE_SKILLS_DIRECT; diff --git a/packages/skills/src/index.ts b/packages/skills/src/index.ts index be9fb07553..7db649b24c 100644 --- a/packages/skills/src/index.ts +++ b/packages/skills/src/index.ts @@ -385,7 +385,13 @@ function shouldLoadPublicCatalog(parsed: ParsedArgs): boolean { if (parsed.command !== "add") return false; if (parsed.copySource && parsed.source) return true; if (parsed.skillNames.length === 0) return true; - return parsed.skillNames.some((name) => !resolveAppForSkill(name)); + return parsed.skillNames.some((name) => !isCoreDelegatedSkill(name)); +} + +function isCoreDelegatedSkill(skillName: string): boolean { + // Rewind uses Core's local Screen Memory installer, not the standalone + // package's hosted MCP descriptor path. + return skillName === "rewind" || Boolean(resolveAppForSkill(skillName)); } const HIDDEN_STANDALONE_BUILT_INS = [ diff --git a/packages/skills/src/telemetry.spec.ts b/packages/skills/src/telemetry.spec.ts index d8740e0674..19f2365762 100644 --- a/packages/skills/src/telemetry.spec.ts +++ b/packages/skills/src/telemetry.spec.ts @@ -59,6 +59,26 @@ describe("createCliTelemetry", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("does not create an installation id until an event is tracked", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "an-telemetry-")); + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.NODE_ENV = "production"; + process.env.AGENT_NATIVE_ANALYTICS_PUBLIC_KEY = "anpk_unit_test_key"; + + createCliTelemetry({ + cli: "skills-installer", + cliVersion: "9.9.9", + command: "add", + interactive: false, + }); + + expect( + fs.existsSync(path.join(home, ".agent-native", "installation-id")), + ).toBe(false); + fs.rmSync(home, { recursive: true, force: true }); + }); + it("falls back to the embedded public key when no env override is set", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "an-telemetry-")); process.env.HOME = home; diff --git a/packages/skills/src/telemetry.ts b/packages/skills/src/telemetry.ts index f38eedf74d..bc407f4242 100644 --- a/packages/skills/src/telemetry.ts +++ b/packages/skills/src/telemetry.ts @@ -198,7 +198,7 @@ export function createCliTelemetry(options: CliTelemetryOptions): CliTelemetry { const publicKey = resolvePublicKey(); const disabled = telemetryDisabled() || !publicKey; const endpoint = resolveEndpoint(); - const installId = disabled ? "" : resolveInstallId(); + let installId: string | undefined = disabled ? "" : undefined; const runId = crypto.randomUUID(); const inFlight = new Set>(); @@ -211,18 +211,18 @@ export function createCliTelemetry(options: CliTelemetryOptions): CliTelemetry { ci: process.env.CI === "true", interactive: options.interactive, runId, - installId, }; function track(event: string, properties?: Record): void { if (disabled) return; + installId ??= resolveInstallId(); const body = JSON.stringify({ publicKey, event, anonymousId: installId, sessionId: runId, timestamp: new Date().toISOString(), - properties: { ...base, ...properties }, + properties: { ...base, installId, ...properties }, }); const promise = fetch(endpoint, { method: "POST",