/ouroboros:welcome

SkillDev tools

Gives new Ouroboros users a guided first-run introduction to the tool.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the /ouroboros:welcome skill

About this capability

First-touch experience for new Ouroboros users

What this skill tells your AI

The instructions your AI receives, as published by q00/ouroboros in skills/welcome/SKILL.md and read by ahel’s review.

Interactive onboarding for new Ouroboros users.

Usage

/ouroboros:welcome              # First-time or update onboarding
/ouroboros:welcome --skip       # Skip welcome, mark as shown
/ouroboros:welcome --force      # Force re-run welcome even if shown

Instructions

When this skill is invoked, follow this flow:

Python Runtime (Required)

Before running any shell snippet below, define this resolver in the same shell. It accepts only Python 3.12 or newer, prefers python3 and then python, and uses uv as the final fallback. Call ouroboros_python directly and quote every argument passed to it; the function preserves arguments and heredoc/stdin input. Only the probe and child interpreter discard inherited CPython path-selection overrides; the caller shell keeps its environment unchanged.

ouroboros_python() {
  if command -v python3 >/dev/null 2>&1 &&
    (unset PYTHONHOME PYTHONPATH PYTHONPLATLIBDIR PYTHONEXECUTABLE __PYVENV_LAUNCHER__; command python3 -c 'import sys; raise SystemExit(sys.version_info < (3, 12))') >/dev/null 2>&1
  then
    (unset PYTHONHOME PYTHONPATH PYTHONPLATLIBDIR PYTHONEXECUTABLE __PYVENV_LAUNCHER__; command python3 "$@")
    return
  fi
  if command -v python >/dev/null 2>&1 &&
    (unset PYTHONHOME PYTHONPATH PYTHONPLATLIBDIR PYTHONEXECUTABLE __PYVENV_LAUNCHER__; command python -c 'import sys; raise SystemExit(sys.version_info < (3, 12))') >/dev/null 2>&1
  then
    (unset PYTHONHOME PYTHONPATH PYTHONPLATLIBDIR PYTHONEXECUTABLE __PYVENV_LAUNCHER__; command python "$@")
    return
  fi
  if command -v uv >/dev/null 2>&1; then
    (unset PYTHONHOME PYTHONPATH PYTHONPLATLIBDIR PYTHONEXECUTABLE __PYVENV_LAUNCHER__; command uv run --no-project --quiet --python '>=3.12' python "$@")
    return
  fi
  printf '%s\n' 'Ouroboros skills require Python >= 3.12 or uv on PATH.' >&2
  return 127
}

Pre-Check: Already Completed?

First, check ~/.ouroboros/prefs.json for welcomeCompleted. For upgrades from older releases, also treat legacy welcomeShown: true as completed so the welcome prompt does not reappear forever:

PREFFILE="$HOME/.ouroboros/prefs.json"

if [ -f "$PREFFILE" ]; then
  WELCOME_COMPLETED=$(ouroboros_python - <<'PY'
import json, os
path = os.path.expanduser('~/.ouroboros/prefs.json')
try:
    prefs = json.load(open(path, encoding='utf-8'))
except Exception:
    prefs = {}
if not isinstance(prefs, dict):
    prefs = {}
print(prefs.get('welcomeCompleted') or ('legacy-welcomeShown' if prefs.get('welcomeShown') else ''))
PY
)
  WELCOME_VERSION=$(ouroboros_python - <<'PY'
import json, os
path = os.path.expanduser('~/.ouroboros/prefs.json')
try:
    prefs = json.load(open(path, encoding='utf-8'))
except Exception:
    prefs = {}
if not isinstance(prefs, dict):
    prefs = {}
print(prefs.get('welcomeVersion') or '')
PY
)

  if [ -n "$WELCOME_COMPLETED" ] && [ "$WELCOME_COMPLETED" != "null" ]; then
    ALREADY_COMPLETED="true"
  fi
fi

Before honoring that completion marker, determine whether setup is ready for the active runtime. A previously completed welcome must never hide the setup gate from a user who chose 나중에 or whose setup was later removed.

First accept a completed Claude Code setup:

if ouroboros_python - "$HOME/.ouroboros/config.yaml" <<'PY'
from __future__ import annotations

import sys
from pathlib import Path

try:
    import yaml
except ModuleNotFoundError:
    yaml = None

config_path = Path(sys.argv[1])

def yaml_mapping(source: str) -> dict[str, dict[str, str]]:
    """Read the top-level mapping scalars this readiness gate owns."""
    if yaml is not None:
        loaded = yaml.safe_load(source) or {}
        return loaded if isinstance(loaded, dict) else {}

    parsed: dict[str, dict[str, str]] = {}
    section: str | None = None

    def scalar_value(raw: str) -> str:
        return raw.strip().split(" #", 1)[0].strip().rstrip(",}").strip().strip("'\"")

    def flow_mapping(raw: str) -> dict[str, str]:
        value = raw.strip().split(" #", 1)[0].strip()
        if not (value.startswith("{") and value.endswith("}")):
            return {}
        fields: dict[str, str] = {}
        for part in value[1:-1].split(","):
            key, separator, field_value = part.partition(":")
            if separator:
                fields[key.strip().strip("'\"")] = scalar_value(field_value)
        return fields

    for raw_line in source.splitlines():
        if not raw_line.strip() or raw_line.lstrip().startswith("#"):
            continue
        indent = len(raw_line) - len(raw_line.lstrip())
        key, separator, raw_value = raw_line.strip().partition(":")
        if not separator:
            continue
        if indent == 0:
            section = key.strip("'\"")
            parsed[section] = flow_mapping(raw_value)
        elif section is not None:
            parsed.setdefault(section, {})[key.strip("'\"")] = scalar_value(raw_value)
    return parsed

try:
    config = yaml_mapping(config_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
    raise SystemExit(1)

orchestrator = config.get("orchestrator") if isinstance(config, dict) else None
llm = config.get("llm") if isinstance(config, dict) else None
# Existing YAML form: runtime_backend: claude. Parsing avoids assuming its order.
# The marketplace plugin owns its MCP capability. Host-owned
# ~/.claude/mcp.json is intentionally not part of SDK setup readiness.
ready = (
    isinstance(orchestrator, dict)
    and orchestrator.get("runtime_backend") in {"claude", "claude_mcp"}
    and isinstance(llm, dict)
    and llm.get("backend") == "claude"
)
raise SystemExit(0 if ready else 1)
PY
then
  SETUP_READY="true"
fi

If SETUP_READY is not true, determine whether the Codex setup is ready:

CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}"
case "$CODEX_HOME_DIR" in
  "~") CODEX_HOME_DIR="$HOME" ;;
  "~/"*) CODEX_HOME_DIR="$HOME/${CODEX_HOME_DIR#"~/"}" ;;
esac
if ouroboros_python - "$HOME/.ouroboros/config.yaml" "$CODEX_HOME_DIR/config.toml" <<'PY'
from __future__ import annotations

import re
import os
import shutil
import sys
from pathlib import Path

try:
    import tomllib
except ModuleNotFoundError:  # Python 3.10 and earlier hosts
    tomllib = None

try:
    import yaml
except ModuleNotFoundError:
    yaml = None

config_path, codex_config_path = map(Path, sys.argv[1:])

def yaml_mapping(source: str) -> dict[str, dict[str, str]]:
    """Read only the top-level mapping scalars owned by this readiness gate."""
    if yaml is not None:
        loaded = yaml.safe_load(source) or {}
        return loaded if isinstance(loaded, dict) else {}

    parsed: dict[str, dict[str, str]] = {}
    section: str | None = None

    def scalar_value(raw: str) -> str:
        return raw.strip().split(" #", 1)[0].strip().rstrip(",}").strip().strip("'\"")

    def flow_mapping(raw: str) -> dict[str, str]:
        value = raw.strip().split(" #", 1)[0].strip()
        if not (value.startswith("{") and value.endswith("}")):
            return {}
        fields: dict[str, str] = {}
        for part in value[1:-1].split(","):
            key, separator, field_value = part.partition(":")
            if separator:
                fields[key.strip().strip("'\"")] = scalar_value(field_value)
        return fields

    for raw_line in source.splitlines():
        if not raw_line.strip() or raw_line.lstrip().startswith("#"):
            continue
        indent = len(raw_line) - len(raw_line.lstrip())
        key, separator, raw_value = raw_line.strip().partition(":")
        if not separator:
            continue
        if indent == 0:
            section = key.strip("'\"")
            parsed[section] = flow_mapping(raw_value)
        elif section is not None:
            parsed[section][key.strip("'\"")] = scalar_value(raw_value)
    return parsed


def toml_mcp_servers(source: str) -> dict[str, dict[str, object]]:
    """Read MCP server table membership when the host lacks ``tomllib``."""
    servers: dict[str, dict[str, object]] = {}
    table: list[str] = []

    def scalar_value(raw: str) -> str:
        value = raw.strip().split(" #", 1)[0].strip().rstrip(",}").strip()
        return value.strip("'\"").strip()

    def inline_value(raw: str, key: str) -> str | None:
        match = re.search(rf"\b{re.escape(key)}\s*=\s*(\"[^\"]*\"|'[^']*'|[^,}}]+)", raw)
        if match is None:
            return None
        return scalar_value(match.group(1))

    for raw_line in source.splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("[") and line.endswith("]"):
            table = [part.strip().strip("'\"") for part in line[1:-1].split(".")]
            if len(table) >= 2 and table[0] == "mcp_servers":
                servers.setdefault(table[1], {})
            continue
        if table == ["mcp_servers"] and "=" in line:
            key, raw_value = line.split("=", 1)
            server = servers.setdefault(key.strip().strip("'\""), {})
            for field in ("command", "url"):
                value = inline_value(raw_value, field)
                if value is not None:
                    server[field] = value
            continue
        if len(table) >= 2 and table[0] == "mcp_servers" and "=" in line:
            key, raw_value = line.split("=", 1)
            key = key.strip().strip("'\"")
            if key in {"command", "url"}:
                servers.setdefault(table[1], {})[key] = scalar_value(raw_value)
    return servers


def executable_candidate(candidate: str) -> bool:
    """Return whether a CLI candidate points to something runnable."""
    value = candidate.strip()
    if not value:
        return False
    if "/" not in value:
        return shutil.which(value) is not None
    path = Path(value).expanduser()
    return path.is_file() and os.access(path, os.X_OK)


def codex_cli_ready(candidate: object) -> bool:
    """Return whether Codex runtime would have an executable launch candidate."""
    if not isinstance(candidate, str):
        return executable_candidate("codex")
    value = candidate.strip()
    if not value:
        return executable_candidate("codex")
    return executable_candidate(value)


def mcp_endpoint_ready(entry: object) -> bool:
    """Return whether the configured MCP endpoint can actually launch."""
    if not isinstance(entry, dict):
        return False
    command = entry.get("command")
    if isinstance(command, str) and command.strip():
        return executable_candidate(command)
    url = entry.get("url")
    return isinstance(url, str) and bool(url.strip())

try:
    config = yaml_mapping(config_path.read_text(encoding="utf-8"))
    codex_source = codex_config_path.read_text(encoding="utf-8")
    codex_config = tomllib.loads(codex_source) if tomllib is not None else {
        "mcp_servers": toml_mcp_servers(codex_source)
    }
except (OSError, ValueError):
    raise SystemExit(1)

orchestrator = config.get("orchestrator") if isinstance(config, dict) else None
llm = config.get("llm") if isinstance(config, dict) else None
# Equivalent to [mcp_servers\.ouroboros], including quoted TOML key forms.
mcp_servers = codex_config.get("mcp_servers") if isinstance(codex_config, dict) else None
ouroboros_mcp = mcp_servers.get("ouroboros") if isinstance(mcp_servers, dict) else None
codex_cli_path = os.environ.get("OUROBOROS_CODEX_CLI_PATH")
if not codex_cli_path and isinstance(orchestrator, dict):
    codex_cli_path = orchestrator.get("codex_cli_path")
ready = (
    isinstance(orchestrator, dict)
    and orchestrator.get("runtime_backend") == "codex"
    and isinstance(llm, dict)
    and llm.get("backend") == "codex"
    and codex_cli_ready(codex_cli_path)
    and mcp_endpoint_ready(ouroboros_mcp)
)
raise SystemExit(0 if ready else 1)
PY
then
  CODEX_READY="true"
fi

Legacy Codex Model Migration

Some older Ouroboros configurations saved gpt-5 into all four stage-model fields. That was a historical default, but it is now an explicit pin and would stop Codex App/CLI model changes from taking effect. Do not silently rewrite a possible user pin. Instead, when Codex is ready, detect that exact legacy shape once before honoring the welcome-completed marker:

if ouroboros_python - "$HOME/.ouroboros/config.yaml" "$HOME/.ouroboros/prefs.json" <<'PY'
from __future__ import annotations

import json
import sys
from pathlib import Path

config_path, prefs_path = map(Path, sys.argv[1:])

def yaml_mapping(source: str) -> dict[str, dict[str, str]]:
    parsed: dict[str, dict[str, str]] = {}
    section: str | None = None
    for raw_line in source.splitlines():
        if not raw_line.strip() or raw_line.lstrip().startswith("#"):
            continue
        indent = len(raw_line) - len(raw_line.lstrip())
        key, separator, raw_value = raw_line.strip().partition(":")
        if not separator:
            continue
        value = raw_value.strip().split(" #", 1)[0].strip().strip("'\"")
        if indent == 0:
            section = key.strip("'\"")
            parsed.setdefault(section, {})
        elif section is not None:
            parsed[section][key.strip("'\"")] = value
    return parsed

try:
    config = yaml_mapping(config_path.read_text(encoding="utf-8"))
except OSError:
    raise SystemExit(1)
try:
    prefs = json.loads(prefs_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
    prefs = {}
if not isinstance(prefs, dict):
    prefs = {}

stage_values = (
    config.get("clarification", {}).get("default_model"),
    config.get("execution", {}).get("default_model"),
    config.get("evaluation", {}).get("semantic_model"),
    config.get("resilience", {}).get("reflect_model"),
)
legacy_gpt5 = all(value == "gpt-5" for value in stage_values)
partial_automatic_migration = (
    any(value == "gpt-5" for value in stage_values)
    and any(value == "default" for value in stage_values)
    and all(value in {"gpt-5", "default"} for value in stage_values)
)
handled = prefs.get("codexModelMigration") in {"automatic-v1", "kept-gpt-5-v1"}
raise SystemExit(0 if (legacy_gpt5 or partial_automatic_migration) and not handled else 1)
PY
then
  LEGACY_CODEX_MODEL_MIGRATION_REQUIRED="true"
fi

If CODEX_READY is true and LEGACY_CODEX_MODEL_MIGRATION_REQUIRED is true:

Use AskUserQuestion:

{
  "questions": [{
    "question": "현재 설정은 모든 단계에서 gpt-5를 고정해 두고 있어요. Codex에서 선택한 모델을 자동으로 사용하도록 바꿀까요?",
    "header": "모델 설정",
    "options": [
      {
        "label": "Codex 선택으로 전환하기 (권장)",
        "description": "App이나 CLI에서 바꾼 모델을 모든 단계가 자동으로 따라가요"
      },
      {
        "label": "gpt-5 고정 유지하기",
        "description": "지금처럼 모든 단계를 gpt-5로 계속 실행해요"
      }
    ],
    "multiSelect": false
  }]
}
  • Codex 선택으로 전환하기: atomically rewrite the four legacy model pins on the current host:
ouroboros_python - "$HOME/.ouroboros/config.yaml" <<'PY'
from __future__ import annotations

import os
import sys
import tempfile
from pathlib import Path

path = Path(sys.argv[1])
original = path.read_text(encoding="utf-8")
replacements = {
    ("clarification", "default_model"): "default",
    ("execution", "default_model"): "default",
    ("evaluation", "semantic_model"): "default",
    ("resilience", "reflect_model"): "default",
}
seen: set[tuple[str, str]] = set()
section: str | None = None
output: list[str] = []
for line in original.splitlines(keepends=True):
    stripped = line.strip()
    indent = len(line) - len(line.lstrip())
    key = stripped.split(":", 1)[0].strip("'\"") if ":" in stripped else ""
    if indent == 0 and ":" in stripped:
        section = key
    target = (section or "", key)
    if indent > 0 and target in replacements:
        newline = "\n" if line.endswith("\n") else ""
        prefix = line[:indent]
        comment = ""
        value_part = line.strip().split(":", 1)[1]
        if " #" in value_part:
            comment = " #" + value_part.split(" #", 1)[1].rstrip("\n")
        output.append(f"{prefix}{key}: {replacements[target]}{comment}{newline}")
        seen.add(target)
    else:
        output.append(line)
missing = set(replacements) - seen
if missing:
    raise SystemExit(f"Cannot migrate Codex model pins; missing keys: {sorted(missing)}")
updated = "".join(output)
fd, tmp_name = tempfile.mkstemp(prefix=".config.yaml.", dir=str(path.parent))
try:
    with os.fdopen(fd, "w", encoding="utf-8") as tmp:
        tmp.write(updated)
        tmp.flush()
        os.fsync(tmp.fileno())
    os.replace(tmp_name, path)
finally:
    try:
        os.unlink(tmp_name)
    except FileNotFoundError:
        pass
PY

default deliberately sends no model pin to Codex; it does not name a model called "default". Confirm that the rewrite succeeded before recording the decision. If this step is interrupted before the marker is written, the next readiness check recognizes the partial gpt-5/default state and offers the migration again.

  • gpt-5 고정 유지하기: do not change config.yaml.

For either completed choice, merge exactly one marker into ~/.ouroboros/prefs.json without deleting existing keys:

ouroboros_python - "automatic-v1" <<'PY'
import json, os, sys
path = os.path.expanduser('~/.ouroboros/prefs.json')
try:
    prefs = json.load(open(path, encoding='utf-8'))
except Exception:
    prefs = {}
if not isinstance(prefs, dict):
    prefs = {}
prefs['codexModelMigration'] = sys.argv[1]
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
    json.dump(prefs, f, indent=2)
    f.write('\n')
PY

Pass kept-gpt-5-v1 instead of automatic-v1 for the keep choice. If welcome was already completed, show a short confirmation and exit after recording this decision; do not make the user answer the generic welcome question too.

If ALREADY_COMPLETED is true, SETUP_READY or CODEX_READY is true, AND no --force flag:

Use AskUserQuestion:

{
  "questions": [{
    "question": "Ouroboros welcome was already completed on $WELCOME_COMPLETED. What would you like to do?",
    "header": "Welcome",
    "options": [
      { "label": "Skip", "description": "Continue to work (recommended)" },
      { "label": "Re-run welcome", "description": "Go through the interactive onboarding again" }
    ],
    "multiSelect": false
  }]
}
  • Skip: Mark as complete and exit
  • Re-run welcome: Continue to Step 1 below

If the welcome was completed but neither SETUP_READY nor CODEX_READY is true, bypass this completion prompt and continue to the Setup Gate below.

If --skip flag present:

  • Merge welcomeShown: true, welcomeCompleted: <current timestamp>, and welcomeVersion into ~/.ouroboros/prefs.json without deleting existing keys:

ouroboros_python - <<'PY' import json, os from datetime import UTC, datetime path = os.path.expanduser('~/.ouroboros/prefs.json') os.makedirs(os.path.dirname(path), exist_ok=True) try: with open(path, encoding='utf-8') as f: prefs = json.load(f) if not isinstance(prefs, dict): prefs = {} except Exception: prefs = {} prefs.update({ 'welcomeShown': True, 'welcomeCompleted': datetime.now(UTC).isoformat(), 'welcomeVersion': '0.50.5', }) with open(path, 'w', encoding='utf-8') as f: json.dump(prefs, f, indent=2) f.write('\n') PY

- Show brief message:

Ouroboros welcome skipped. Run /ouroboros:welcome --force to re-run onboarding.

- Exit

---

### Setup Gate: First Use

Before showing the welcome banner, check whether setup is prepared for the
active runtime on this machine. A global `config.yaml` alone is not enough: it
must name a runtime this gate recognizes.

First accept a completed **Claude Code** setup. The marketplace plugin owns
its MCP capability; host-owned `~/.claude/mcp.json` is intentionally not part
of SDK setup readiness:

```bash
if ouroboros_python - "$HOME/.ouroboros/config.yaml" <<'PY'
from __future__ import annotations

import sys
from pathlib import Path

try:
  import yaml
except ModuleNotFoundError:
  yaml = None

config_path = Path(sys.argv[1])

def yaml_mapping(source: str) -> dict[str, dict[str, str]]:
  """Read only the top-level mapping scalars owned by this readiness gate."""
  if yaml is not None:
      loaded = yaml.safe_load(source) or {}
      return loaded if isinstance(loaded, dict) else {}

  parsed: dict[str, dict[str, str]] = {}
  section: str | None = None

  def scalar_value(raw: str) -> str:
      return raw.strip().split(" #", 1)[0].strip().rstrip(",}").strip().strip("'\"")

  def flow_mapping(raw: str) -> dict[str, str]:
      value = raw.strip().split(" #", 1)[0].strip()
      if not (value.startswith("{") and value.endswith("}")):
          return {}
      fields: dict[str, str] = {}
      for part in value[1:-1].split(","):
          key, separator, field_value = part.partition(":")
          if separator:
              fields[key.strip().strip("'\"")] = scalar_value(field_value)
      return fields

  for raw_line in source.splitlines():
      if not raw_line.strip() or raw_line.lstrip().startswith("#"):
          continue
      indent = len(raw_line) - len(raw_line.lstrip())
      key, separator, raw_value = raw_line.strip().partition(":")
      if not separator:
          continue
      if indent == 0:
          section = key.strip("'\"")
          parsed[section] = flow_mapping(raw_value)
      elif section is not None:
          parsed.setdefault(section, {})[key.strip("'\"")] = scalar_value(raw_value)
  return parsed

try:
  config = yaml_mapping(config_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
  raise SystemExit(1)

orchestrator = config.get("orchestrator") if isinstance(config, dict) else None
llm = config.get("llm") if isinstance(config, dict) else None
# Existing YAML form: runtime_backend: claude. Parsing avoids assuming its order.
# The marketplace plugin owns its MCP capability. Host-owned
# ~/.claude/mcp.json is intentionally not part of SDK setup readiness.
ready = (
  isinstance(orchestrator, dict)
  and orchestrator.get("runtime_backend") in {"claude", "claude_mcp"}
  and isinstance(llm, dict)
  and llm.get("backend") == "claude"
)
raise SystemExit(0 if ready else 1)
PY
then
echo "SETUP_READY"
else
echo "SETUP_REQUIRED"
fi

If the Claude gate printed SETUP_READY, setup is complete: skip the Codex gate below and continue directly to the welcome banner.

Otherwise check whether Codex is prepared:

CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}"
case "$CODEX_HOME_DIR" in
  "~") CODEX_HOME_DIR="$HOME" ;;
  "~/"*) CODEX_HOME_DIR="$HOME/${CODEX_HOME_DIR#"~/"}" ;;
esac
if ouroboros_python - "$HOME/.ouroboros/config.yaml" "$CODEX_HOME_DIR/config.toml" <<'PY'
from __future__ import annotations

import re
import os
import shutil
import sys
from pathlib import Path

try:
    import tomllib
except ModuleNotFoundError:  # Python 3.10 and earlier hosts
    tomllib = None

try:
    import yaml
except ModuleNotFoundError:
    yaml = None

config_path, codex_config_path = map(Path, sys.argv[1:])

def yaml_mapping(source: str) -> dict[str, dict[str, str]]:
    """Read only the top-level mapping scalars owned by this readiness gate."""
    if yaml is not None:
        loaded = yaml.safe_load(source) or {}
        return loaded if isinstance(loaded, dict) else {}

    parsed: dict[str, dict[str, str]] = {}
    section: str | None = None

    def scalar_value(raw: str) -> str:
        return raw.strip().split(" #", 1)[0].strip().rstrip(",}").strip().strip("'\"")

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
6k
Forks
594
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
welcome-q00
Source
github.com/q00/ouroboros