airbyte_ops_mcp.cli.devin

CLI commands for Devin session operations.

Commands:

airbyte-ops devin secret-request - Deliver a secret after Slack approval airbyte-ops devin secret-list - List available secrets in the vault

CLI reference

The commands below are regenerated by poe docs-generate via cyclopts's programmatic docs API; see docs/generate_cli.py.

airbyte-ops devin COMMAND

Devin session operations.

Commands:

airbyte-ops devin secret-list

airbyte-ops devin secret-list

List available secrets in the 1Password vault.

Lists all item titles in the 'devin-on-demand-secrets' vault and outputs them as compact single-line JSON. The MCP tool reads this output from the workflow job logs to return the list to the caller.

NOTE: We intentionally use print(json.dumps(...)) instead of print_json() (Rich) so the output stays on one log line, which the MCP log parser can reliably extract.

airbyte-ops devin secret-request

airbyte-ops devin secret-request SESSION SECRET-ALIAS APPROVAL-EVIDENCE-URL [ARGS]

Deliver a Devin secret after Slack approval.

Validates the approval record, creates a 1Password share link, and sends it to the Devin session.

NOTE: The approval request (Phase 1) is handled entirely by the GitHub Actions workflow (bash + reusable HITL workflow), not by this CLI command.

Parameters:

  • SESSION, --session: Devin session ID or URL (e.g. 'abc123...' or 'https://appairbyte_ops_mcp.cli.devin.ai/sessions/abc123...'). [required]
  • SECRET-ALIAS, --secret-alias: Secret name matching an item title in the 'devin-on-demand-secrets' 1Password vault. [required]
  • APPROVAL-EVIDENCE-URL, --approval-evidence-url: Slack approval record URL. Validates the approval and delivers the secret to the Devin session. [required]
  • EXPECTED-REQUEST-ID, --expected-request-id: Request ID from Phase 1. When provided, the approval record is validated against this ID for replay protection.
  1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
  2"""CLI commands for Devin session operations.
  3
  4Commands:
  5    airbyte-ops devin secret-request - Deliver a secret after Slack approval
  6    airbyte-ops devin secret-list    - List available secrets in the vault
  7
  8## CLI reference
  9
 10The commands below are regenerated by `poe docs-generate` via cyclopts's
 11programmatic docs API; see `docs/generate_cli.py`.
 12
 13.. include:: ../../../docs/generated/cli/devin.md
 14   :start-line: 2
 15"""
 16
 17from __future__ import annotations
 18
 19# Hide Python-level members from the pdoc page for this module; the rendered
 20# docs for this CLI group come entirely from the grafted `.. include::` in
 21# the module docstring above.
 22__all__: list[str] = []
 23
 24import json
 25import logging
 26import os
 27import subprocess
 28from typing import Annotated
 29
 30import requests
 31from cyclopts import Parameter
 32
 33from airbyte_ops_mcp.cli._base import App, app
 34from airbyte_ops_mcp.cli._shared import exit_with_error, print_json, print_success
 35from airbyte_ops_mcp.devin_api import (
 36    extract_session_id,
 37    send_session_message,
 38)
 39from airbyte_ops_mcp.onepassword import (
 40    DEFAULT_SHARE_EXPIRY,
 41    create_share_link,
 42    list_vault_items,
 43)
 44from airbyte_ops_mcp.slack_api import (
 45    SlackAPIError,
 46    SlackApprovalRecordError,
 47    SlackURLParseError,
 48    validate_slack_approval_record,
 49)
 50
 51logger = logging.getLogger(__name__)
 52
 53# ---------------------------------------------------------------------------
 54# Sub-app registration
 55# ---------------------------------------------------------------------------
 56
 57devin_app = App(name="devin", help="Devin session operations.")
 58app.command(devin_app)
 59
 60
 61# ---------------------------------------------------------------------------
 62# CLI commands
 63# ---------------------------------------------------------------------------
 64
 65
 66@devin_app.command(name="secret-list")
 67def secret_list() -> None:
 68    """List available secrets in the 1Password vault.
 69
 70    Lists all item titles in the 'devin-on-demand-secrets' vault and
 71    outputs them as compact single-line JSON. The MCP tool reads this
 72    output from the workflow job logs to return the list to the caller.
 73
 74    NOTE: We intentionally use `print(json.dumps(...))` instead of
 75    `print_json()` (Rich) so the output stays on one log line, which
 76    the MCP log parser can reliably extract.
 77    """
 78    titles = list_vault_items()
 79    print(json.dumps({"available_secrets": titles, "available_count": len(titles)}))
 80
 81
 82@devin_app.command(name="secret-request")
 83def secret_request(
 84    session: Annotated[
 85        str,
 86        Parameter(
 87            help="Devin session ID or URL "
 88            "(e.g. 'abc123...' or 'https://app.devin.ai/sessions/abc123...').",
 89        ),
 90    ],
 91    secret_alias: Annotated[
 92        str,
 93        Parameter(
 94            help="Secret name matching an item title in the "
 95            "'devin-on-demand-secrets' 1Password vault.",
 96        ),
 97    ],
 98    approval_evidence_url: Annotated[
 99        str,
100        Parameter(
101            help="Slack approval record URL. Validates the approval "
102            "and delivers the secret to the Devin session.",
103        ),
104    ],
105    expected_request_id: Annotated[
106        str | None,
107        Parameter(
108            help="Request ID from Phase 1. When provided, the "
109            "approval record is validated against this ID for replay "
110            "protection.",
111        ),
112    ] = None,
113) -> None:
114    """Deliver a Devin secret after Slack approval.
115
116    Validates the approval record, creates a 1Password share link,
117    and sends it to the Devin session.
118
119    NOTE: The approval *request* (Phase 1) is handled entirely by the
120    GitHub Actions workflow (bash + reusable HITL workflow), not by
121    this CLI command.
122    """
123    try:
124        session_id = extract_session_id(session)
125    except ValueError as exc:
126        exit_with_error(str(exc))
127        return  # unreachable, but keeps the type checker happy
128
129    _handle_secret_delivery(
130        session_id=session_id,
131        secret_alias=secret_alias,
132        approval_evidence_url=approval_evidence_url,
133        expected_request_id=expected_request_id,
134    )
135
136
137# ---------------------------------------------------------------------------
138# Phase handlers
139# ---------------------------------------------------------------------------
140
141
142def _handle_secret_delivery(
143    session_id: str,
144    secret_alias: str,
145    approval_evidence_url: str,
146    *,
147    expected_request_id: str | None = None,
148) -> None:
149    """Phase 2: Validate approval, create share link, deliver to session."""
150    # Step 1: Validate the Slack approval record
151    logger.info("Validating approval evidence: %s", approval_evidence_url)
152    try:
153        record = validate_slack_approval_record(
154            approval_evidence_url,
155            expected_secret_alias=secret_alias,
156            expected_request_id=expected_request_id,
157        )
158    except (SlackURLParseError, SlackAPIError, SlackApprovalRecordError) as exc:
159        exit_with_error(str(exc))
160        return  # unreachable
161
162    approver = record.user_name or record.user_id
163    logger.info("Approval validated: approved by %s", approver)
164
165    # Step 2: Create 1Password share link
166    logger.info("Creating 1Password share link for '%s'...", secret_alias)
167    try:
168        share_link = create_share_link(secret_alias)
169    except (RuntimeError, subprocess.TimeoutExpired) as exc:
170        _handle_share_link_failure(
171            secret_alias=secret_alias, session_id=session_id, error=exc
172        )
173        return  # unreachable
174
175    # Mask the share link in GitHub Actions logs
176    if os.environ.get("GITHUB_ACTIONS"):
177        print(f"::add-mask::{share_link}")
178
179    logger.info("Share link created (expires in %s)", DEFAULT_SHARE_EXPIRY)
180
181    # Step 3: Send to Devin session
182    logger.info("Sending share link to session %s...", session_id[:8])
183    message = (
184        f"Your requested secret `{secret_alias}` is ready.\n\n"
185        f"Open this link in your browser to view and copy the secret values:\n"
186        f"{share_link}\n\n"
187        f"This link expires in {DEFAULT_SHARE_EXPIRY}. "
188        f"Retrieve and store the value immediately in your secure local storage."
189    )
190    try:
191        send_session_message(session_id, message)
192    except (requests.RequestException, RuntimeError) as exc:
193        exit_with_error(f"Failed to send message to session: {exc}")
194        return  # unreachable
195
196    print_success("Secret delivered successfully!")
197    print_json(
198        {
199            "phase": "delivered",
200            "secret_alias": secret_alias,
201            "session_id": session_id,
202            "approver": approver,
203        }
204    )
205
206
207def _handle_share_link_failure(
208    secret_alias: str,
209    session_id: str,
210    error: Exception,
211) -> None:
212    """Handle a failed share-link creation by listing available secrets.
213
214    Attempts to list all available secret names in the vault so the agent
215    (and approver) can see valid options. This helps recover from typos
216    or incorrect secret names.
217    """
218    logger.warning("Share link creation failed for '%s': %s", secret_alias, error)
219
220    available_hint = ""
221    try:
222        titles = list_vault_items()
223        if titles:
224            formatted = ", ".join(f"`{t}`" for t in titles)
225            available_hint = f" Available secrets: {formatted}"
226    except (RuntimeError, subprocess.TimeoutExpired, OSError):
227        logger.warning("Could not list vault items to suggest alternatives.")
228
229    error_message = (
230        f"Failed to create share link for `{secret_alias}`: {error}{available_hint}"
231    )
232    # Best-effort: notify the session so the agent can self-correct
233    try:
234        send_session_message(session_id, error_message)
235    except (requests.RequestException, RuntimeError):
236        logger.warning(
237            "Could not send failure notification to session %s", session_id[:8]
238        )
239
240    exit_with_error(f"Failed to create share link for '{secret_alias}': {error}")