diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 0e3e1b2..4e95bd0 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,11 +1,11 @@ { "name": "1password", - "version": "1.1.0", - "description": "1Password plugin for Cursor — securely manage development secrets.", + "version": "1.3.0", + "description": "1Password plugin for Cursor — hooks, bundled agent skill, and MCP config for Developer Environments and local .env mounts. Secret values stay in 1Password.", "author": { "name": "1Password" }, - "homepage": "https://1password.com", + "homepage": "https://www.1password.dev/", "repository": "https://github.com/1Password/cursor-plugin", "license": "MIT", "keywords": [ @@ -16,8 +16,12 @@ "env", "dotenv", "hooks", - "validation" + "validation", + "mcp", + "developer-environments" ], - "logo": "assets/logo.svg", - "hooks": "hooks/hooks.json" + "logo": "assets/icon.svg", + "hooks": "hooks/hooks.json", + "skills": "./skills/", + "mcpServers": "./mcp.json" } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95ed20e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store + +# Environment files (keep templates committable) +.env +.env.* +!.env.example +!.env.sample +!.env.template +!.env.dist + +# Python +__pycache__/ +*.py[cod] + +# Local dev hook wiring (use when team policy blocks local plugin imports) +.cursor/hooks.json diff --git a/README.md b/README.md index ffb5555..5b7a88d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # 1Password Plugin for Cursor -The official [1Password](https://1password.com) plugin for [Cursor](https://cursor.com). It brings 1Password's secret management capabilities directly into your editor, helping you develop securely without leaving your workflow. +The official [1Password](https://1password.com) plugin for [Cursor](https://cursor.com). It ships three pieces that work together: **hooks** that validate locally mounted `.env` files, an **agent skill** with the complete Developer Environment workflow, and **MCP configuration** for the 1Password desktop app server. Secret values stay in 1Password — the agent sees variable names and mount paths, not secret contents. + +Install the **plugin** (not a hand-configured MCP entry alone). The bundled `1password-environments` skill and rule are the authoritative agent workflow; the MCP server's built-in documentation resources cover tool basics only and omit import-and-mount steps. For more on 1Password's developer tools, see the [1Password Developer Documentation](https://developer.1password.com). @@ -9,7 +11,11 @@ For more on 1Password's developer tools, see the [1Password Developer Documentat - [1Password](https://1password.com) subscription - [1Password for Mac or Linux](https://1password.com/downloads) - [Cursor](https://cursor.com) -- [sqlite3](https://www.sqlite.org/) installed and available in your `PATH` (pre-installed on macOS; install via your package manager on Linux) + +Additional requirements by feature: + +- **Hooks** — [sqlite3](https://www.sqlite.org/) installed and available in your `PATH` (pre-installed on macOS; install via your package manager on Linux) +- **MCP** — 1Password Labs **MCP Server** experiment enabled in the desktop app (`onepassword://settings/labs`). If the setting is missing, your account may not have the `ai-local-mcp-server` feature flag. > **Note:** 1Password Environments local `.env` mounts only apply on **macOS and Linux**. **`hooks.json`** invokes **`./scripts/validate-mounted-env-files`** with no extension. On **macOS / Linux**, that runs the **Bash** script. On **Windows** the shell looks for a real file by trying suffixes from **`PATHEXT`** until one matches on disk. That yields **`validate-mounted-env-files.cmd`**, which returns **`allow`** and skips validation so agent shells are not blocked. @@ -24,7 +30,7 @@ Before using this plugin, you'll need to configure your secrets in 1Password: ### Step 2: Install the plugin -Install from the [Cursor Marketplace](https://cursor.com/marketplace): +Install from the [Cursor Marketplace](https://cursor.com/marketplace). This registers hooks, the `1password-environments` agent skill, and `mcp.json` together. Do not add the 1Password MCP server manually in user settings instead of installing the plugin — agents will get MCP tools without the skill workflow. 1. Open **Cursor Settings** > **Plugins**. 2. Search for **1password**. @@ -32,6 +38,24 @@ Install from the [Cursor Marketplace](https://cursor.com/marketplace): Or use the command palette: `Ctrl+Shift+P` (or `Cmd+Shift+P` on macOS) > **Plugins: Install Plugin** > search for `1password`. +Or install directly: + +``` +/add-plugin 1password +``` + +### Step 3: Enable MCP in 1Password (required for Environment management) + +Enable the **MCP Server** experiment in the 1Password desktop app: open **Settings → Labs** (or use `onepassword://settings/labs`) and turn on **MCP Server**. The plugin's `mcp.json` connects Cursor to that server after this step. + +The MCP server binary on macOS: + +```text +/Applications/1Password.app/Contents/MacOS/1password-mcp +``` + +On Linux, see the [1Password MCP server documentation](https://www.1password.dev/environments/mcp-server) for the binary path on your platform. + ## Features ### Hooks @@ -122,23 +146,76 @@ DEBUG=1 echo '{"command": "echo test", "workspace_roots": ["/path/to/your/projec When not running in debug mode, the hook writes logs to `/tmp/1password-cursor-hooks.log`. Log entries include timestamps and details about 1Password queries, validation results, and permission decisions. +### MCP and agent skill + +The plugin connects Cursor to the local 1Password MCP server and bundles the **`1password-environments`** skill and **rule** (`skills/1password-environments/SKILL.md`, `rules/1password-environments.mdc`). Agents MUST read that skill before calling MCP tools. + +A **`stop` / `afterMCPExecution` hook** (`scripts/nudge-1password-import`) injects the remaining import steps when an agent stops after `append_variables` without mounting — the common failure mode for `.env` imports. + +The MCP server exposes `1password://docs/getting-started` and `1password://docs/environments-guide`, but those resources are **not** sufficient for agent workflows — they omit importing a plain `.env` file and mounting at the source path by default. The bundled skill defines the complete workflow, including import, append, and mount. + +#### Example prompts + +- "List my 1Password Environments" +- "Mount my staging Environment as `.env` in this repo" +- "What variables are in my production Environment?" +- "Create a new Environment called `my-app-dev`" +- "Create an Environment from my project `.env` file" +- "Import `.env` into 1Password and mount it here" +- "Add a placeholder for my OpenAI API key" + +#### MCP tools + +| Tool | Description | +|------|-------------| +| `authenticate` | Authenticate with the 1Password desktop app; returns `accountId` | +| `list_environments` | List Developer Environments for an account | +| `create_environment` | Create a new Developer Environment | +| `rename_environment` | Rename an existing Developer Environment | +| `list_variables` | List variable names in an Environment (no values) | +| `append_variables` | Add or update Environment variables | +| `create_local_env_file` | Mount an Environment as a local `.env` file | +| `list_local_env_files` | List existing local `.env` mounts for an Environment | + +Confirm the MCP server is connected in **Cursor Settings → MCP** after installing the plugin and enabling the Labs experiment in 1Password. + ## Plugin Structure ``` -1password/ +cursor-plugin/ ├── .cursor-plugin/ │ └── plugin.json # Plugin manifest ├── hooks/ │ └── hooks.json # Hook event configuration +├── skills/ +│ └── 1password-environments/ +│ ├── SKILL.md # Agent skill for MCP workflows +│ └── reference.md # Mount conflict and troubleshooting +├── rules/ +│ └── 1password-environments.mdc # Agent rule (activates on 1Password MCP work) +├── mcp.json # MCP server configuration ├── assets/ -│ └── logo.svg # Plugin logo +│ ├── logo.svg # Plugin logo +│ └── icon.svg ├── scripts/ │ ├── validate-mounted-env-files # Bash hook (macOS / Linux) -│ └── validate-mounted-env-files.cmd # Windows cmd wrapper returns allow (validation skipped) +│ ├── validate-mounted-env-files.cmd # Windows cmd wrapper returns allow (validation skipped) +│ ├── deny-env-file-read # Blocks Read on secret .env paths +│ └── nudge-1password-import # Nudges agents to finish .env import + mount ├── LICENSE └── README.md ``` +## Local development + +Symlink the repository root for local testing: + +```bash +ln -s /path/to/cursor-plugin ~/.cursor/plugins/local/1password +``` + +Reload Cursor after creating or updating the symlink. + ## Telemetry The plugin emits **opt-in** telemetry so 1Password can understand plugin adoption and the prevalence of common failure modes (missing files, disabled mounts). Two event types are emitted: @@ -159,6 +236,7 @@ The plugin emits **opt-in** telemetry so 1Password can understand plugin adoptio ## Resources - [Validate local `.env` files with Cursor Agent](https://developer.1password.com/docs/environments/cursor-hook-validate/) — full setup guide on the 1Password Developer site +- [1Password MCP server documentation](https://www.1password.dev/environments/mcp-server) - [1Password Agent Hooks](https://github.com/1Password/agent-hooks) — the original hooks repository this plugin is based on - [1Password Environments](https://developer.1password.com/docs/environments) — documentation for 1Password's environment and secrets management - [1Password Local `.env` Files](https://developer.1password.com/docs/environments/local-env-file) — how local `.env` file mounting works diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..eda328f --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logo-dark.png b/assets/logo-dark.png new file mode 100644 index 0000000..9f83808 Binary files /dev/null and b/assets/logo-dark.png differ diff --git a/assets/logo-light.png b/assets/logo-light.png new file mode 100644 index 0000000..d167b26 Binary files /dev/null and b/assets/logo-light.png differ diff --git a/hooks/hooks.json b/hooks/hooks.json index d57cfee..b09dbb2 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -5,6 +5,37 @@ { "command": "./scripts/validate-mounted-env-files" } + ], + "preToolUse": [ + { + "command": "./scripts/deny-env-file-read", + "matcher": "Read File V2|Read", + "failClosed": true + } + ], + "beforeReadFile": [ + { + "command": "./scripts/deny-env-file-read", + "failClosed": true + } + ], + "afterMCPExecution": [ + { + "command": "./scripts/nudge-1password-import", + "matcher": "append_variables|create_environment|create_local_env_file|list_variables" + } + ], + "postToolUse": [ + { + "command": "./scripts/nudge-1password-import", + "matcher": "MCP:append_variables|MCP:create_environment|MCP:create_local_env_file|MCP:list_variables" + } + ], + "stop": [ + { + "command": "./scripts/nudge-1password-import", + "loop_limit": 3 + } ] } } diff --git a/mcp.json b/mcp.json new file mode 100644 index 0000000..90b8b4e --- /dev/null +++ b/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "1password": { + "command": "/Applications/1Password.app/Contents/MacOS/1password-mcp", + "args": [] + } + } +} diff --git a/rules/1password-environments.mdc b/rules/1password-environments.mdc new file mode 100644 index 0000000..731d8b7 --- /dev/null +++ b/rules/1password-environments.mdc @@ -0,0 +1,34 @@ +--- +description: >- + 1Password Developer Environments — import, mount, or manage repo .env secrets + via the 1password MCP server. Apply before any 1Password MCP call or when the + user mentions 1Password environments, .env import/migrate/mount, or project secrets. +alwaysApply: false +--- + +# 1Password Environments (Cursor plugin) + +Read `skills/1password-environments/SKILL.md` once at the start of the turn, then execute. Do not read MCP tool descriptor files under `mcps/`. Do not fetch `1password://docs/getting-started` or `environments-guide` — they omit import-and-mount steps. + +## Done means + +| User intent | Done when | +|-------------|-----------| +| Import / create from `.env` | Variables in 1Password **and** `create_local_env_file` mount verified via `list_local_env_files` | +| Mount existing Environment | Mount exists at requested path (`list_local_env_files`) | +| List / rename / add vars | Requested MCP action completed | + +**Import is not done after `append_variables`.** `list_variables` does not verify a mount. + +## Import sequence (no shortcuts) + +1. `authenticate` → store `accountId` +2. Parse source `.env` with shell `grep` (never Read on `.env`) +3. `create_environment` or resolve existing → `append_variables` +4. `list_local_env_files` — skip `create_local_env_file` if mount already at path +5. `create_local_env_file` at source absolute path (workspace `/.env` when user says "here" / "project root") +6. Verify with `list_local_env_files` again + +Do not tell the user the task is complete, and do not offer mounting as an optional follow-up, until step 6 passes. + +Mount conflict (Read hook vs shell validation): see `skills/1password-environments/reference.md`. diff --git a/scripts/deny-env-file-read b/scripts/deny-env-file-read new file mode 100755 index 0000000..5af3229 --- /dev/null +++ b/scripts/deny-env-file-read @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Block Read/preToolUse access to .env files. + +Cursor often blocks or hangs when the agent uses Read on .env paths — including +plain ASCII files on disk, not only 1Password FIFO mounts. Deny those reads and +steer the agent toward shell parsing or MCP tools instead. +""" + +from __future__ import annotations + +import json +import os +import re +import sys + +# Templates without secrets are OK to read for variable names. +_ENV_READ_ALLOWLIST = frozenset( + { + ".env.example", + ".env.sample", + ".env.template", + ".env.dist", + } +) + +_READ_TOOL_NAME = re.compile(r"^Read(?: File V2)?$", re.IGNORECASE) + +DENY_MESSAGE = ( + "Do not use the Read tool on .env files. Parse with shell grep instead, " + "then finish the 1password-environments import workflow: append_variables, " + "create_local_env_file at the source path, verify with list_local_env_files. " + 'Example: grep -E \'^[A-Za-z_][A-Za-z0-9_]*=\' "$path" | grep -v \'^#\'. ' + "For mount conflicts see the plugin skill reference.md." +) + + +def _debug(message: str) -> None: + if os.environ.get("DEBUG"): + with open("/tmp/1password-cursor-hooks.log", "a", encoding="utf-8") as log: + log.write(f"deny-env-file-read: {message}\n") + + +def _file_path_from_payload(data: dict) -> str: + path = data.get("file_path") + if isinstance(path, str) and path: + return path + + tool_input = data.get("tool_input") + if isinstance(tool_input, dict): + for key in ("path", "targetFile", "file_path", "filePath"): + value = tool_input.get(key) + if isinstance(value, str) and value: + return value + + return "" + + +def _is_read_event(data: dict) -> bool: + hook_event = data.get("hook_event_name") + if hook_event == "beforeReadFile": + return True + + tool_name = data.get("tool_name") + if isinstance(tool_name, str) and _READ_TOOL_NAME.match(tool_name.strip()): + return True + + # Some Cursor builds omit tool_name on beforeReadFile-style preToolUse payloads. + if data.get("file_path") and not tool_name: + return True + + return False + + +def _is_env_secret_path(path: str) -> bool: + base = os.path.basename(path) + if base == ".env": + return True + if base.startswith(".env.") and base not in _ENV_READ_ALLOWLIST: + return True + return False + + +def _deny() -> None: + print( + json.dumps( + { + "permission": "deny", + "user_message": DENY_MESSAGE, + "agent_message": DENY_MESSAGE, + } + ) + ) + sys.exit(2) + + +def _allow() -> None: + print(json.dumps({"permission": "allow"})) + + +def main() -> None: + raw = sys.stdin.read() + if not raw.strip(): + _allow() + return + + try: + data = json.loads(raw) + except json.JSONDecodeError: + _allow() + return + + _debug(json.dumps({"event": data.get("hook_event_name"), "tool_name": data.get("tool_name")})) + + if not _is_read_event(data): + _allow() + return + + file_path = _file_path_from_payload(data) + if not file_path or not os.path.exists(file_path): + _allow() + return + + if _is_env_secret_path(file_path): + _debug(f"deny {file_path}") + _deny() + + _allow() + + +if __name__ == "__main__": + main() diff --git a/scripts/nudge-1password-import b/scripts/nudge-1password-import new file mode 100755 index 0000000..10fd0aa --- /dev/null +++ b/scripts/nudge-1password-import @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Track 1Password import progress and nudge the agent to finish mounting. + +After append_variables during an import, many agents stop early. This hook +injects additional_context (after MCP) and, if the agent still tries to finish, +a stop-hook followup_message until create_local_env_file clears pending state. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +STATE_DIR = Path("/tmp/1password-cursor-plugin/import-state") + +APPEND_NUDGE = ( + "1Password import checkpoint: append_variables succeeded but the import is NOT " + "complete. Required next MCP calls in order: (1) list_local_env_files — skip " + "create_local_env_file if the source path is already mounted; (2) " + "create_local_env_file with mountPath set to the source .env absolute path " + "(project root /.env when the user said here or project root); (3) " + "list_local_env_files again to verify. list_variables does NOT verify a mount. " + "Do NOT tell the user the task is done. Do NOT offer mounting as an optional " + "follow-up." +) + +LIST_VARS_NUDGE = ( + "1Password import still incomplete: listing variable names does not create or " + "verify a local mount. If you imported from a .env file, call " + "list_local_env_files then create_local_env_file at the source path, then verify " + "with list_local_env_files before responding to the user." +) + +STOP_FOLLOWUP = ( + "Continue the 1Password .env import from where you left off: call " + "list_local_env_files, then create_local_env_file at the source .env absolute " + "path for this workspace, then verify with list_local_env_files. Read the " + "1password-environments plugin skill if needed. Do not reply to the user until " + "the mount is verified or the user explicitly declined mounting." +) + +_CREATE_ENV = re.compile(r"create_environment", re.IGNORECASE) +_APPEND_VARS = re.compile(r"append_variables", re.IGNORECASE) +_LIST_VARS = re.compile(r"list_variables", re.IGNORECASE) +_CREATE_MOUNT = re.compile(r"create_local_env_file", re.IGNORECASE) + + +def _debug(message: str) -> None: + if os.environ.get("DEBUG"): + STATE_DIR.mkdir(parents=True, exist_ok=True) + with (STATE_DIR / "nudge.log").open("a", encoding="utf-8") as log: + log.write(message + "\n") + + +def _tool_name(data: dict) -> str: + for key in ("tool_name", "toolName"): + value = data.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def _normalize_tool_name(name: str) -> str: + base = name.rsplit("-", 1)[-1] + return base.lower() + + +def _parse_tool_input(data: dict) -> dict: + raw = data.get("tool_input") + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass + return {} + + +def _mcp_succeeded(data: dict) -> bool: + raw = data.get("result_json") + if not isinstance(raw, str) or not raw.strip(): + return True + try: + result = json.loads(raw) + except json.JSONDecodeError: + return True + if isinstance(result, dict): + if result.get("error"): + return False + if result.get("isError") is True: + return False + return True + + +def _state_path(conversation_id: str) -> Path: + safe = re.sub(r"[^A-Za-z0-9._-]", "_", conversation_id or "unknown") + return STATE_DIR / f"{safe}.json" + + +def _load_state(conversation_id: str) -> dict: + path = _state_path(conversation_id) + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def _save_state(conversation_id: str, state: dict) -> None: + if not conversation_id: + return + STATE_DIR.mkdir(parents=True, exist_ok=True) + _state_path(conversation_id).write_text(json.dumps(state, indent=2), encoding="utf-8") + + +def _clear_state(conversation_id: str) -> None: + path = _state_path(conversation_id) + try: + path.unlink(missing_ok=True) + except OSError: + pass + + +def _variable_count(tool_input: dict) -> int: + variables = tool_input.get("variables") + return len(variables) if isinstance(variables, list) else 0 + + +def _should_track_mount(state: dict, tool_input: dict) -> bool: + if state.get("created_environment_this_session"): + return True + return _variable_count(tool_input) >= 2 + + +def _emit(additional_context: str | None = None, followup_message: str | None = None) -> None: + payload: dict[str, str] = {} + if additional_context: + payload["additional_context"] = additional_context + if followup_message: + payload["followup_message"] = followup_message + if payload: + print(json.dumps(payload)) + else: + print("{}") + + +def _handle_after_mcp(data: dict) -> None: + conversation_id = data.get("conversation_id") or data.get("session_id") or "" + tool = _normalize_tool_name(_tool_name(data)) + tool_input = _parse_tool_input(data) + + if not _mcp_succeeded(data): + _emit() + return + + state = _load_state(conversation_id) + + if _CREATE_ENV.search(tool): + state["created_environment_this_session"] = True + _save_state(conversation_id, state) + _emit() + return + + if _CREATE_MOUNT.search(tool): + _clear_state(conversation_id) + _emit() + return + + if _APPEND_VARS.search(tool): + if _should_track_mount(state, tool_input): + state["pending_mount"] = True + workspace_roots = data.get("workspace_roots") + if isinstance(workspace_roots, list) and workspace_roots: + state["workspace_root"] = workspace_roots[0] + for key in ("accountId", "environmentId", "environmentName"): + if key in tool_input: + state[key] = tool_input[key] + _save_state(conversation_id, state) + _emit(additional_context=APPEND_NUDGE) + return + _save_state(conversation_id, state) + _emit() + return + + if _LIST_VARS.search(tool) and state.get("pending_mount"): + _emit(additional_context=LIST_VARS_NUDGE) + return + + _emit() + + +def _handle_stop(data: dict) -> None: + conversation_id = data.get("conversation_id") or data.get("session_id") or "" + state = _load_state(conversation_id) + if state.get("pending_mount"): + _emit(followup_message=STOP_FOLLOWUP) + return + _emit() + + +def main() -> None: + raw = sys.stdin.read() + if not raw.strip(): + _emit() + return + + try: + data = json.loads(raw) + except json.JSONDecodeError: + _emit() + return + + event = data.get("hook_event_name") or "" + _debug(f"{event} tool={_tool_name(data)!r}") + + if event == "stop": + _handle_stop(data) + return + + if event in ("afterMCPExecution", "postToolUse"): + _handle_after_mcp(data) + return + + _emit() + + +if __name__ == "__main__": + main() diff --git a/skills/1password-environments/SKILL.md b/skills/1password-environments/SKILL.md new file mode 100644 index 0000000..23bc8fe --- /dev/null +++ b/skills/1password-environments/SKILL.md @@ -0,0 +1,87 @@ +--- +name: 1password-environments +description: >- + Canonical workflow for 1Password Developer Environments in Cursor via MCP. + Use when creating, importing, migrating, or mounting .env files; managing repo + secrets; listing Environment variable names; or calling any 1password MCP tool. + Read this entire file once before the first MCP call in the turn. +--- + +# 1Password Environments + +## Not done until + +**Import / create from `.env`** (including "using values from the project `.env`"): + +- [ ] Environment created or resolved +- [ ] Variables appended via `append_variables` +- [ ] `create_local_env_file` at the **source** `.env` absolute path +- [ ] Mount verified with `list_local_env_files` + +Stopping after `create_environment` + `append_variables` is wrong. `list_variables` is not mount verification. + +**Mount only:** mount exists at the requested path (`list_local_env_files`). + +## Do not + +- Read files under `mcps/**/tools/*.json` — call MCP tools directly +- Fetch `1password://docs/getting-started` or `environments-guide` — they omit import-and-mount; this skill is the workflow +- Read secret `.env` paths — parse with `grep`; templates (`.env.example`, etc.) may be Read +- Report success or offer "want me to mount?" before the import checklist is complete + +## MCP tools + +Parameters use camelCase (`accountId`, `environmentId`). Responses may use snake_case. + +| Tool | Use | +|------|-----| +| `authenticate` | First call each turn; returns `accountId` | +| `list_environments` | Resolve Environment by name | +| `create_environment` | New empty Environment (import continues below) | +| `rename_environment` | Rename | +| `list_variables` | Names only — never values | +| `append_variables` | `{ name, value, concealed }` — secrets `concealed: true` | +| `list_local_env_files` | Check existing mounts | +| `create_local_env_file` | Mount at `mountPath` (macOS/Linux) | + +## Import from a plain `.env` file + +Default path: `{workspace_root}/.env` unless the user names another path. + +1. **Plain file check:** `test -f "$path" && ! test -p "$path"`. If FIFO, use `list_local_env_files` instead of importing from disk. +2. **Parse** (do not Read the secret path): + + ```bash + grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$path" | grep -v '^#' + ``` + + Strip optional quotes; pass values to MCP only — do not paste into chat. +3. **`authenticate`** → `accountId` +4. **`create_environment`** (new name) or **`list_environments`** (existing) +5. **`append_variables`** with all parsed variables +6. **Mount** (required unless user declined): + - `list_local_env_files` — skip if mount already at path + - `create_local_env_file` with `accountId`, `environmentId`, `environmentName`, `mountPath` (absolute source path) + - `list_local_env_files` again to verify + +If shell `grep` is blocked, see [reference.md](reference.md) (mount conflict). + +## Other flows + +**Mount existing Environment:** authenticate → resolve Environment → step 6 above. + +**Inspect names:** authenticate → resolve → `list_variables` → summarize names only. + +**Rename:** authenticate → resolve → confirm name → `rename_environment`. + +**Add/update variables:** authenticate → resolve → `list_variables` → `append_variables`. + +## Safety + +- Never reveal secret values in chat +- Never Read mounted FIFO `.env` files — use MCP for mounts +- Ask before changing variables unless the request is explicit + +## Troubleshooting + +Mount conflict, validation modes, setup: [reference.md](reference.md) diff --git a/skills/1password-environments/reference.md b/skills/1password-environments/reference.md new file mode 100644 index 0000000..b9aacf3 --- /dev/null +++ b/skills/1password-environments/reference.md @@ -0,0 +1,51 @@ +# 1Password Environments — reference + +## Import completion nudges (plugin hook) + +The plugin runs `scripts/nudge-1password-import` after relevant 1Password MCP calls and on agent stop. If you appended variables during an import but have not called `create_local_env_file`, the hook injects the next required steps. Treat that as mandatory — do not reply to the user until the mount is verified. + +## Mount conflict (Read hook vs shell validation hook) + +The Read hook denies `.env` reads; the `beforeShellExecution` hook blocks **all** shell commands when 1Password expects a mount at a path that is missing, disabled, or not a FIFO (for example a plain `.env` still on disk at the mount path). That is a policy deadlock: Read is denied, then shell is denied too. + +Validation scope depends on `.1password/environments.toml`: + +- No TOML file (or no `mount_paths` field) — default mode: all 1Password mount destinations for this workspace are validated. +- `mount_paths = [".env", ...]` — only listed paths are validated. +- `mount_paths = []` — validation disabled for this repo; all shell commands allowed. + +If shell parsing fails with a message about missing, invalid, or disabled environment files: + +1. Check whether 1Password already has a destination for the same path (`list_local_env_files`, or the 1Password app Destinations tab). +2. Run `test -p "$path"` — if false while 1Password lists that path, the file is plain text colliding with an expected mount. +3. Help the user pick a workaround: + - Copy the source to a non-mount path and parse that copy (e.g. `cp .env .env.import` then `grep` `.env.import`). + - Temporarily set `mount_paths = []` in `.1password/environments.toml` to disable mount validation for this repo. + - Fix the mount in 1Password (enable the destination, or remove it until migration finishes). + - Paste `KEY=value` lines into chat instead of parsing from disk. + +If parsing via shell is blocked for other reasons, ask the user to paste `KEY=value` lines (without values in follow-up messages if they prefer). + +## Setup and troubleshooting + +**Requirements** + +- macOS or Linux with the 1Password desktop app installed (local `.env` mounts are macOS/Linux only; on Windows the validation hook is a no-op). +- **1Password Cursor plugin installed** (marketplace or local symlink) so this skill, hooks, and MCP config load together. +- 1Password Labs **MCP Server** experiment enabled in the desktop app (`onepassword://settings/labs`). +- Access to a 1Password account with Developer Environments enabled. + +The MCP server binary on macOS: + +```text +/Applications/1Password.app/Contents/MacOS/1password-mcp +``` + +On Linux, see the [1Password MCP server documentation](https://www.1password.dev/environments/mcp-server) for the binary path on your platform. + +**When things fail** + +- Authentication or environment access fails — the 1Password desktop app may need approval, unlocking, or account access. +- MCP server unavailable — enable the **1Password Labs MCP Server** experiment via `onepassword://settings/labs`. If the Labs setting is missing, the account may not have the required `ai-local-mcp-server` feature flag. +- `create_local_env_file` fails — confirm the user is on macOS or Linux. +- Shell commands denied during a plain `.env` import — see **Mount conflict** above.