plugins/lisa-cursor/skills/lisa-setup-notion/SKILL.md
Configure Notion as the PRD source for this project. Walks the user through creating an internal integration in the target workspace, sharing the PRD database with it, stores the resulting `ntn_*` token in OS keychain (multi-workspace-safe — keyed by workspaceId), validates against the Notion API, and writes `notion.workspaceId`, `notion.prdDatabaseId`, and `notion.values` into `.lisa.config.json`. Idempotent — re-runs update the existing section rather than duplicating it. Offers to set top-level `source: "notion"`.
npx skillsauth add codyswanngt/lisa lisa-setup-notionInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
Security scan pending...
This skill is queued for security scanning. Results will appear when the scan completes.
Provision Notion access for this project. After this skill runs, .lisa.config.json contains notion.workspaceId and notion.prdDatabaseId, and the OS keychain has a lisa-notion entry keyed by the workspaceId.
Ask via AskUserQuestion:
How do you want lisa to talk to Notion for this project?
- MCP-only (simplest) — authenticate the Notion MCP once via browser OAuth; lisa uses it for every operation. Best for: single-workspace developers on a personal laptop. New developers onboard with one OAuth flow, no token sharing, no rotation pain when someone leaves. Skip the rest of this setup.
- MCP + internal-integration token (recommended for teams) — MCP for interactive dev, internal-integration token in keychain for headless / CI / multi-workspace. Continue through token-create steps.
- Token-only (headless / CI) — store a workspace-scoped internal-integration token in the OS keychain; lisa uses curl for everything. Best for: CI pipelines, headless containers. Continue through token-create steps.
If the user picks (1) and the MCP is already authenticated to the right workspace (verify by attempting to fetch the configured prdDatabaseId via the MCP — success means identity match), write only notion.workspaceId, notion.prdDatabaseId, and notion.statusProperty into .lisa.config.json and skip to Step 8 (top-level source offer). If the MCP isn't authed yet, instruct the user to run mcp__claude_ai_Notion__authenticate (or the plugin equivalent) and complete the OAuth flow, then re-verify. The PRD database still needs to be shared with the OAuth-granted access — Notion's per-page sharing model applies to OAuth identities the same way it does to internal-integration tokens.
If the user picks (2) or (3), continue through the rest of the steps; the token gets stored in addition to (or instead of) the MCP session.
case "$(uname -s)" in
Darwin) open "https://www.notion.so/profile/integrations" ;;
Linux) xdg-open "https://www.notion.so/profile/integrations" 2>/dev/null ;;
MINGW*|MSYS*|CYGWIN*) start "https://www.notion.so/profile/integrations" ;;
esac
Print instructions for the user:
1. Click "New integration".
2. Name it: lisa-<project-name> (e.g., lisa-gemini, lisa-acme — pick something descriptive).
3. Associated workspace: pick the workspace your PRDs live in.
4. Type: "Internal integration".
5. Capabilities: leave defaults (Read content, Update content, Insert content). No comment / user-info capabilities needed.
6. Click Save.
7. On the integration's detail page, click "Show" next to "Internal Integration Token".
8. Copy the token — starts with `ntn_`. Atlassian-style scoped tokens have a `=<CRC>` suffix; Notion's do NOT, but tokens are still 50+ chars. Watch for clipboard-truncation in some terminals.
The user picks a stable identifier for this workspace. Two options:
AcmeOrgB). Easy to recognize, can be ambiguous if a workspace is renamed in Notion. Recommended.Default to the workspace name. After the user stores the token (Step 4), Step 5's /users/me call surfaces the actual bot.workspace_name; if it differs from what the user typed (capitalization, trailing whitespace), prompt to confirm.
WORKSPACE=$(jq -r '.notion.workspaceId // empty' .lisa.config.json 2>/dev/null)
if [ -z "$WORKSPACE" ]; then
# Prompt the user — accept any non-empty string. They pick the slug; we just store it.
read -p "Workspace identifier (any stable slug, e.g. 'acme-org-b'): " WORKSPACE
fi
This is non-optional. Notion's permission model is share-based — the integration cannot see any pages or databases until the user explicitly grants access.
Print instructions:
1. In Notion, navigate to your PRD database (or the parent page containing it).
2. Click the "..." menu in the top right of the database.
3. Click "Connections".
4. Find "lisa-<project>" in the list and click "Connect".
5. Confirm any prompts about granting access.
The connection cascades to all child pages of the database by default. New PRDs added under the database automatically inherit access.
If --database=<uuid> was passed in $ARGUMENTS, use it; otherwise prompt:
DATABASE_ID=$(jq -r '.notion.prdDatabaseId // empty' .lisa.config.json 2>/dev/null)
if [ -z "$DATABASE_ID" ]; then
cat <<EOF
Paste the PRD database URL or ID:
- URL form: https://notion.so/<workspace>/<database-id>?v=...
- ID form: 32-char UUID with or without dashes
EOF
read -p "Database: " DB_INPUT
# Extract UUID from URL if needed.
DATABASE_ID=$(echo "$DB_INPUT" | grep -oE '[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}' | head -1)
if [ -z "$DATABASE_ID" ]; then
echo "Error: could not extract a UUID from '$DB_INPUT'." >&2
exit 1
fi
fi
Same security posture as setup-atlassian. Print a platform-specific clipboard-pipe command for the user to run in their own terminal:
case "$(uname -s)" in
Darwin)
cat <<EOF
1. Copy the integration token from the Notion page.
2. Run this single line in your terminal (leading space keeps it out of zsh history):
security delete-generic-password -s lisa-notion -a "$WORKSPACE" 2>/dev/null; TOK="\$(pbpaste)"; security add-generic-password -U -s lisa-notion -a "$WORKSPACE" -w "\$TOK"; unset TOK
The token is piped from clipboard straight to keychain — never enters the prompt or chat.
EOF
;;
Linux)
if command -v secret-tool >/dev/null 2>&1; then
if command -v wl-paste >/dev/null 2>&1; then CLIP=wl-paste
elif command -v xclip >/dev/null 2>&1; then CLIP="xclip -selection clipboard -o"
elif command -v xsel >/dev/null 2>&1; then CLIP="xsel --clipboard --output"
else CLIP="cat"
fi
cat <<EOF
1. Copy the integration token.
2. Run this in your terminal:
secret-tool clear service lisa-notion account "$WORKSPACE" 2>/dev/null; printf '%s' "\$($CLIP)" | secret-tool store --label="Lisa Notion ($WORKSPACE)" service lisa-notion account "$WORKSPACE"
(If no clipboard tool is installed: the command reads from stdin — paste, Ctrl-D.)
EOF
else
cat <<EOF
libsecret / secret-tool not installed. Options:
1. Install: sudo apt install libsecret-tools (then re-run /lisa:setup:notion).
2. Env-var fallback (headless / CI / Docker):
export NOTION_API_TOKEN_$(echo "$WORKSPACE" | tr '[:upper:]-' '[:lower:]_')="<paste-token>"
Plaintext on disk — only acceptable on ephemeral / CI environments.
EOF
fi
;;
MINGW*|MSYS*|CYGWIN*)
cat <<EOF
PowerShell:
\$tok = Get-Clipboard; cmdkey /generic:"lisa-notion-$WORKSPACE" /user:"$WORKSPACE" /pass:"\$tok"; Remove-Variable tok
EOF
;;
esac
Never accept the token via chat or stdin into this skill. Wait for the user to confirm storage.
Use the same lookup ladder notion-access uses:
read_notion_token() {
local workspace="$1"
[ -n "$NOTION_API_TOKEN" ] && { echo "$NOTION_API_TOKEN"; return; }
local slug=$(echo "$workspace" | tr '[:upper:]-' '[:lower:]_')
local varname="NOTION_API_TOKEN_${slug}"
[ -n "${!varname}" ] && { echo "${!varname}"; return; }
# Preferred path: the single secrets chokepoint. It owns the one-store rule
# and the surface ladder, so anything it can answer must not be read out of an
# OS keychain here — a second reader is how the same credential ends up living
# in two places and drifting. Keep the LADDER below identical to
# `notion-access`; the tail may differ, because this skill falls through to a
# legacy keychain rung and that one does not. Saying "the ladder" rather
# than "this function" is deliberate — the old wording asked for something
# unachievable, and an unachievable rule is one that gets quietly dropped.
#
# Ordered across trusted machine-managed substrates, ending at the installed
# package. Checkout-local paths are deliberately absent: a familiar generated
# destination is still repository-controlled executable code. The plugin
# rungs are the floor: `resolve-secret.mjs` ships beside this skill, so a rung
# pointing at it is reachable from anywhere the plugin itself is installed.
# Without one, a consumer repository that vendors none of the leading paths
# never reaches a resolver at all — the ladder exits without having asked
# anything, which is what pushed agents into improvising their own credential
# lookups. This LADDER is identical in every skill that resolves a credential
# and `credential-resolver-ladder` fails if any copy diverges. Only what
# happens AFTER the ladder may differ between them.
# Execute only machine-managed plugin/package resolvers. Checkout-local
# candidates are repository-controlled code, not trusted merely by path.
local candidates=()
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
candidates+=("$CLAUDE_PLUGIN_ROOT/skills/lisa-secrets-access/scripts/resolve-secret.mjs")
fi
if [ -n "${PLUGIN_ROOT:-}" ]; then
candidates+=("$PLUGIN_ROOT/skills/lisa-secrets-access/scripts/resolve-secret.mjs")
fi
# Last rung deliberately needs no environment variable: an agent that was
# never handed a plugin root still has the installed package to fall back on.
candidates+=(node_modules/@codyswann/lisa/plugins/lisa/skills/lisa-secrets-access/scripts/resolve-secret.mjs)
local resolver
local tried=()
for resolver in "${candidates[@]}"; do
tried+=("$resolver")
if [ -f "$resolver" ]; then
local via_lisa
via_lisa=$(node "$resolver" get NOTION_API_TOKEN 2>/dev/null) \
&& [ -n "$via_lisa" ] && { echo "$via_lisa"; return; }
# Empty/error means this substrate had no answer; try the next trusted one.
fi
done
# Legacy fallback: the OS keychain written by the guided flow below, for
# projects that have not adopted a credentials provider. Reached only when the
# chokepoint is absent or has no entry.
local from_keychain=""
case "$(uname -s)" in
Darwin) from_keychain=$(security find-generic-password -s lisa-notion -a "$workspace" -w 2>/dev/null) ;;
Linux) command -v secret-tool >/dev/null && \
from_keychain=$(secret-tool lookup service lisa-notion account "$workspace" 2>/dev/null) ;;
MINGW*|MSYS*|CYGWIN*)
# `cmdkey /generic ... /pass:` stores the secret in Windows Credential Manager, but
# `cmdkey /list` never prints stored passwords (by design). Read the CredentialBlob
# back via the Win32 CredRead API through PowerShell; pass the target name via an env
# var to dodge nested quoting, and strip the CRLF powershell.exe appends.
from_keychain=$(LISA_CRED_TARGET="lisa-notion-${workspace}" powershell.exe -NoProfile -NonInteractive -Command '
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class LisaCred {
[StructLayout(LayoutKind.Sequential)]
private struct CREDENTIAL {
public int Flags; public int Type; public IntPtr TargetName; public IntPtr Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist;
public int AttributeCount; public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName;
}
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
private static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
[DllImport("advapi32.dll")] private static extern void CredFree(IntPtr cred);
public static string Read(string target) {
IntPtr p;
if (!CredRead(target, 1, 0, out p)) { return null; }
try {
CREDENTIAL c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));
if (c.CredentialBlobSize == 0) { return String.Empty; }
return Marshal.PtrToStringUni(c.CredentialBlob, c.CredentialBlobSize / 2);
} finally { CredFree(p); }
}
}
"@
[LisaCred]::Read($env:LISA_CRED_TARGET)' 2>/dev/null | tr -d '\r') ;;
esac
[ -n "$from_keychain" ] && { echo "$from_keychain"; return; }
# Name every path. A bare `return 1` sends the next reader hunting for a
# resolver they cannot see the absence of; the enumeration turns that into a
# seconds-long diagnosis. Paths and store coordinates only — never any
# resolved value, on any path.
echo "Error: could not resolve NOTION_API_TOKEN through lisa-secrets-access or the legacy keychain." >&2
echo "Tried, in order (relative paths are from $PWD):" >&2
printf ' %s\n' "${tried[@]}" >&2
echo " <OS keychain> service=lisa-notion account=$workspace" >&2
return 1
}
TOKEN=$(read_notion_token "$WORKSPACE")
if [ -z "$TOKEN" ]; then
echo "Error: token not retrievable after store. Re-run Step 4." >&2
exit 1
fi
# Notion tokens — sanity length check. Internal-integration tokens are ~50+ chars; if drastically shorter, a paste truncation happened.
if [ ${#TOKEN} -lt 40 ]; then
echo "Warning: token is ${#TOKEN} chars — Notion tokens are typically 50+. Possible truncation." >&2
fi
ME=$(curl -s -H "Authorization: Bearer $TOKEN" \
-H "Notion-Version: 2022-06-28" \
"https://api.notion.com/v1/users/me")
ME_WORKSPACE=$(echo "$ME" | jq -r '.bot.workspace_name // empty')
if [ -z "$ME_WORKSPACE" ]; then
echo "Error: token failed Notion /users/me probe. Response: $ME" >&2
exit 1
fi
# If the user typed a workspace name that differs from what Notion returns, prompt to align.
if [ "$ME_WORKSPACE" != "$WORKSPACE" ]; then
cat <<EOF
The token belongs to workspace '$ME_WORKSPACE', but you provided '$WORKSPACE' as the identifier.
Use the Notion-returned name for consistency? (recommended — connection-match check uses this string)
EOF
# AskUserQuestion: replace WORKSPACE with $ME_WORKSPACE? recommended yes
fi
# Verify database visibility too (Notion's share model means the token sees only what's been shared).
DB_PROBE=$(curl -s -o /tmp/setup-notion-db -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" -H "Notion-Version: 2022-06-28" \
"https://api.notion.com/v1/databases/$DATABASE_ID")
if [ "$DB_PROBE" != "200" ]; then
cat >&2 <<EOF
Error: integration cannot see database $DATABASE_ID (HTTP $DB_PROBE).
The most likely cause is that you skipped Step 3 — sharing the database with the integration.
Open the database in Notion → "..." → Connections → add 'lisa-<project>' → retry.
EOF
exit 1
fi
echo "Token validated. Workspace: $ME_WORKSPACE. Database visible."
Read the database schema and find the Status property's value list. Compare to lisa defaults and prompt for overrides if names differ.
DB_SCHEMA=$(curl -s -H "Authorization: Bearer $TOKEN" -H "Notion-Version: 2022-06-28" \
"https://api.notion.com/v1/databases/$DATABASE_ID")
STATUS_PROP=$(jq -r '.properties | to_entries[] | select(.value.type == "status" or .value.type == "select") | .key' <<<"$DB_SCHEMA" | head -1)
STATUS_VALUES=$(jq -r --arg p "$STATUS_PROP" '.properties[$p] | (.status.options // .select.options) | .[].name' <<<"$DB_SCHEMA")
For each lisa role (draft, ready, in_review, blocked, ticketed, shipped, verified), check if its default name (Draft, Ready, etc.; Verified for verified) appears in $STATUS_VALUES. If a role's default is missing but a similar-looking value exists, prompt the user to map it via AskUserQuestion. If a role has no plausible match, prompt to either create the value in Notion or accept that the lifecycle stage is unrepresented. This find-or-create-or-accept path is idempotent per role: a value already present (default or mapped) is reused untouched, so re-running never duplicates a status option.
verified is the terminal lifecycle state after shipped (the verified role from the config-resolution rule, #591): /lisa:verify-prd transitions a Notion PRD into it once the shipped product has been empirically verified against the PRD. Notion models the PRD lifecycle as Status (or select) property options rather than labels, so verified is mapped or created through the exact same path as every other role above — and, like them, persisted to notion.values.verified when the workspace uses a non-default option name.
Collect overrides as a partial values map. Only write keys that differ from defaults — verified included, so a non-default Verified option name lands in notion.values.verified.
.lisa.config.jsonjq --arg ws "$WORKSPACE" --arg db "$DATABASE_ID" --arg sp "$STATUS_PROP" --argjson values "$VALUES_JSON" '
.notion = ((.notion // {})
| .workspaceId = $ws
| .prdDatabaseId = $db
| (if $sp != "" then .statusProperty = $sp else . end)
| (if $values != {} then .values = $values else . end))
' .lisa.config.json > .lisa.config.json.tmp \
&& mv .lisa.config.json.tmp .lisa.config.json
VALUES_JSON is {} if all roles use the default names; otherwise contains only the overrides.
sourceIf .source is unset or differs from "notion", ask via AskUserQuestion:
Notion is configured. Set top-level
source: "notion"so/lisa:intake(with no args) scans this database for PRDs?
If yes:
jq '.source = "notion"' .lisa.config.json > .lisa.config.json.tmp \
&& mv .lisa.config.json.tmp .lisa.config.json
jq -e '.notion.workspaceId and .notion.prdDatabaseId' .lisa.config.json >/dev/null
echo "Token validated (${#TOKEN} chars). Workspace: $ME_WORKSPACE. Database: $DATABASE_ID."
Report success with the resolved workspace, database, status property name, and value overrides (if any), confirming all lifecycle roles — including the terminal verified — were detected, mapped, or flagged for creation. Direct the user to /lisa:intake to test.
notion section without disturbing others. The keychain entry update in Step 4 is the user's manual action — they re-run the same security / secret-tool / cmdkey command.notion.workspaceId and notion.prdDatabaseId already exist in config, skip the prompts in Steps 2–3 and go straight to verification..lisa.config.json. Tokens stay in keychain or env..lisa.config.local.json notion.workspaceId is the sole disambiguator. There is no "active workspace" concept on the Notion side — the token IS the workspace binding.development
Prepare a machine — a fresh laptop or a throwaway container — to run coding agents, before any repository exists. Detects which of Lisa's supported agents (Claude Code, Codex, Cursor, OpenCode, Antigravity, Copilot) are already installed, asks which credential manager the machine uses (Bitwarden, 1Password, Doppler, Vault, AWS, or none), and installs only what is missing, each by its vendor's own preferred method. Idempotent, headless by default, and emits a Dockerfile for a spin-up/spin-down environment. Run it on a new machine, in a container, or before cloning anything.
tools
Provision and verify a remote execution environment for a host project — Codex Cloud today, other remote surfaces as they are added. Generates a repository-owned setup script that installs the declared toolchain, materializes secrets through lisa-secrets-access, and runs the project's own hook. Provisions by API where one exists, by driving the vendor console where one does not, and by emitting exact config otherwise — then proves the result with the same read-back regardless of which tier did the work. Use before dispatching any work with executionEnv.
tools
Bring a developer's machine in line with the toolchain the project declares. Reports every tool in remoteEnv.tools that is missing, outdated, or unpinned for this platform, and installs the missing ones into ~/.local/bin from the same pinned, checksummed entries the remote surfaces use — but only when asked. Same manifest, same pins, same installers as lisa-setup-remote-env; what differs is consent and that the pin is a floor rather than an equality. Run it on a fresh checkout, after a manifest change, or when a tool fails at the moment of use.
tools
Route one unit of work to a remote execution surface. Reads the executionEnv parameter (local by default, codex-cloud or claude-web today), verifies the environment is provisioned and bound to this repository, submits a thin skill invocation, records the task identifier to .lisa/remote-dispatch.json, and exits without polling. Routing only — the remote runs the identical skill from the identical repository. Composable and inline: other skills invoke it via the Skill tool rather than users calling it directly.