Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,8 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

# GitHub Pages requires these permissions on the deploy job.
permissions:
contents: read
pages: write
id-token: write

# Allow only one concurrent deploy; cancel in-progress runs on a new push.
concurrency:
Expand Down Expand Up @@ -63,6 +60,10 @@ jobs:
name: Deploy to GitHub Pages
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/Python-3.11+-green.svg)](https://python.org)
[![PyPI](https://img.shields.io/pypi/v/claude-ctx.svg)](https://pypi.org/project/claude-ctx/)
[![Tests](https://img.shields.io/badge/Tests-3843_collected-brightgreen.svg)](#)
[![Tests](https://img.shields.io/badge/Tests-3872_collected-brightgreen.svg)](#)
[![Graph](https://img.shields.io/badge/Graph-102%2C928_nodes_/_2.9M_edges-red.svg)](graph/)
[![Docs](https://img.shields.io/badge/docs-MkDocs_Material-blue.svg)](https://stevesolun.github.io/ctx/)
[![Repo views](https://hits.sh/github.com/stevesolun/ctx.svg?label=repo%20views)](https://hits.sh/github.com/stevesolun/ctx/)
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ ones are flagged. New ones self-ingest.
---

**v1.0.9** — MIT, CI-matrixed (Ubuntu 3.12 plus Windows/macOS 3.11/3.12),
3,843 tests collected. Ships console scripts including `ctx-init`,
3,872 tests collected. Ships console scripts including `ctx-init`,
`ctx-monitor` (local dashboard with graph + wiki + load/unload for
skills, agents, and MCP servers, plus Harness Setup for user-owned LLMs),
`ctx-incremental-attach`, `ctx-incremental-shadow`, `ctx-dedup-check`
Expand Down
10 changes: 7 additions & 3 deletions scripts/ci_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ def _normalize_path(path: str) -> str:
return path.strip().lstrip("\ufeff").replace("\\", "/")


def _is_graph_artifact_path(path: str) -> bool:
if _matches(path, GRAPH_ARTIFACT_PATTERNS):
return True
return _matches(path, ("graph/**",)) and path != "graph/README.md"


def classify_paths(paths: Iterable[str]) -> dict[str, bool]:
files = [
normalized
Expand All @@ -94,9 +100,7 @@ def classify_paths(paths: Iterable[str]) -> dict[str, bool]:
]
ci_changed = any(_matches(path, (".github/workflows/**",)) for path in files)
docs_changed = any(_matches(path, DOCS_PATTERNS) for path in files)
graph_artifact_changed = any(
_matches(path, GRAPH_ARTIFACT_PATTERNS) for path in files
)
graph_artifact_changed = any(_is_graph_artifact_path(path) for path in files)
graph_only = bool(files) and all(_matches(path, ("graph/**",)) for path in files)
return {
"browser_changed": ci_changed
Expand Down
10 changes: 5 additions & 5 deletions scripts/ci_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@
"--min-semantic-edges",
"1000000",
"--expected-nodes",
"102925",
"102928",
"--expected-edges",
"2913930",
"2913960",
"--expected-semantic-edges",
"1683163",
"1683193",
"--expected-harness-nodes",
"207",
"--expected-skills-sh-nodes",
Expand All @@ -51,11 +51,11 @@
"--expected-skills-sh-converted",
"89465",
"--expected-skill-pages",
"91463",
"91464",
"--expected-agent-pages",
"467",
"--expected-mcp-pages",
"10788",
"10790",
"--expected-harness-pages",
"207",
"--line-threshold",
Expand Down
5 changes: 5 additions & 0 deletions scripts/ci_required.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ def failed_required_jobs(
event_name == "pull_request"
and _job_output(needs, "classify", "graph_only") == "true"
)
graph_changed_pr = (
event_name == "pull_request"
and _job_output(needs, "classify", "graph_changed") == "true"
)
graph_artifact_changed_pr = (
event_name == "pull_request"
and _job_output(needs, "classify", "graph_artifact_changed") == "true"
Expand Down Expand Up @@ -96,6 +100,7 @@ def failed_required_jobs(
and name == "graph-check"
and result == "skipped"
and not graph_artifact_changed_pr
and not (graph_changed_pr and not docs_only_pr)
):
continue
if (
Expand Down
36 changes: 26 additions & 10 deletions scripts/overlay_wiki_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import argparse
import json
import sys
import tarfile
import tempfile
from collections import Counter
Expand All @@ -18,9 +19,13 @@
from pathlib import Path
from typing import Any

from ctx.core.wiki.artifact_promotion import promote_staged_artifact
from ctx.utils._fs_utils import atomic_write_text, reject_symlink_path
from scripts.build_dashboard_graph_index import build_dashboard_index
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))

from ctx.core.wiki.artifact_promotion import promote_staged_artifact # noqa: E402
from ctx.utils._fs_utils import atomic_write_text, reject_symlink_path # noqa: E402
from scripts.build_dashboard_graph_index import build_dashboard_index # noqa: E402

GRAPH_EXPORT_NAMES = {
"graphify-out/graph.json",
Expand Down Expand Up @@ -215,7 +220,7 @@ def _collect_replacements(
entity_type, slug = _split_node_id(node_id)
page = _entity_page(source_wiki, entity_type, slug)
if page is not None and (not runtime or entity_type == "harness"):
replacements[page.relative_to(source_wiki).as_posix()] = page.read_bytes()
replacements[page.relative_to(source_wiki).as_posix()] = _read_safe_bytes(page)
if not runtime and entity_type == "skill":
replacements.update(_skill_replacements(source_wiki, slug, skills_root=skills_root))
return replacements
Expand All @@ -241,12 +246,13 @@ def _entity_page(source_wiki: Path, entity_type: str, slug: str) -> Path | None:
],
}.get(entity_type, [])
for candidate in candidates:
if candidate.is_file():
if _is_safe_file(candidate):
return candidate
if entity_type == "mcp-server":
matches = list((source_wiki / "entities" / "mcp-servers").rglob(f"{slug}.md"))
if matches:
return matches[0]
for match in matches:
if _is_safe_file(match):
return match
return None


Expand All @@ -257,16 +263,26 @@ def _skill_replacements(source_wiki: Path, slug: str, *, skills_root: Path | Non
root / slug / "SKILL.md",
]
for candidate in candidates:
if candidate.is_file():
if _is_safe_file(candidate):
skill_dir = candidate.parent
return {
f"converted/{slug}/{path.relative_to(skill_dir).as_posix()}": path.read_bytes()
f"converted/{slug}/{path.relative_to(skill_dir).as_posix()}": _read_safe_bytes(path)
for path in sorted(skill_dir.rglob("*"))
if path.is_file() and not path.name.endswith((".original", ".lock"))
if _is_safe_file(path) and not path.name.endswith((".original", ".lock"))
}
return {}


def _is_safe_file(path: Path) -> bool:
reject_symlink_path(path)
return path.is_file()


def _read_safe_bytes(path: Path) -> bytes:
reject_symlink_path(path)
return path.read_bytes()


def _rewrite_tarball(tarball: Path, replacements: dict[str, bytes]) -> None:
reject_symlink_path(tarball)
staged = tarball.with_name(f"{tarball.name}.staged")
Expand Down
36 changes: 33 additions & 3 deletions src/ctx/adapters/claude_code/inject_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,40 @@ def merge_hooks(existing: dict, new_hooks: dict) -> dict:
new_cmd = new_entry.get("command", "")
new_hooks_list = new_entry.get("hooks", [])

# Check if any command in this entry already exists
new_cmds = {new_cmd} if new_cmd else {h.get("command", "") for h in new_hooks_list}
if not new_cmds.intersection(existing_commands):
if isinstance(new_hooks_list, list) and new_hooks_list:
missing_hooks = [
hook for hook in new_hooks_list
if isinstance(hook, dict)
and hook.get("command")
and hook.get("command") not in existing_commands
]
if not missing_hooks:
continue
matcher = new_entry.get("matcher")
target_entry = next(
(
entry for entry in existing_list
if isinstance(entry, dict)
and entry.get("matcher") == matcher
and isinstance(entry.get("hooks"), list)
),
None,
)
if target_entry is not None:
target_entry["hooks"].extend(missing_hooks)
else:
entry = dict(new_entry)
entry["hooks"] = missing_hooks
existing_list.append(entry)
existing_commands.update(
hook["command"] for hook in missing_hooks
if isinstance(hook.get("command"), str)
)
continue

if new_cmd and new_cmd not in existing_commands:
existing_list.append(new_entry)
existing_commands.add(new_cmd)

return existing

Expand Down
4 changes: 2 additions & 2 deletions src/ctx/adapters/claude_code/install/install_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,20 @@

import json
import logging
import os
import re
import shutil
from pathlib import Path
from typing import Callable, Literal

from ctx_config import cfg
from ctx.utils._fs_utils import atomic_write_text as _atomic_write_text
from ctx.utils._file_lock import file_lock

_logger = logging.getLogger(__name__)

EntityType = Literal["skill", "agent", "mcp-server"]

MANIFEST_PATH = Path(os.path.expanduser("~/.claude/skill-manifest.json"))
MANIFEST_PATH = cfg.skill_manifest

_FRONTMATTER_HEAD_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)

Expand Down
69 changes: 60 additions & 9 deletions src/ctx/adapters/claude_code/install/mcp_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
# npx / uvx / bunx intentionally unrestricted — they ARE the
# package-launcher pattern MCP servers are expected to use.
}
_WINDOWS_EXEC_SUFFIXES = (".exe", ".cmd", ".bat", ".ps1")

_SECRET_KEY_MARKERS: tuple[str, ...] = (
"token",
Expand Down Expand Up @@ -141,7 +142,7 @@ def _rejects_banned_args(tokens: list[str]) -> str | None:
for the rest."""
if not tokens:
return None
exe = tokens[0]
exe = _normalized_executable(tokens[0])
banned = _BANNED_INTERPRETER_ARGS.get(exe)
if banned is None:
return None
Expand Down Expand Up @@ -208,6 +209,42 @@ def _find_inline_secret(obj: object, *, path: str = "") -> str | None:
return None


def _normalized_executable(value: str) -> str:
name = Path(value).name.lower()
for suffix in _WINDOWS_EXEC_SUFFIXES:
if name.endswith(suffix):
return name[: -len(suffix)]
return name


def _find_inline_secret_arg(tokens: list[str]) -> str | None:
for token in tokens:
assignment = _SECRET_ASSIGNMENT_RE.search(token)
if assignment and not _placeholder_secret_value(assignment.group(2)):
return assignment.group(1)
for pattern in _TOKEN_VALUE_PATTERNS:
if pattern.search(token):
return token
if token.startswith("--") and "=" in token:
key, value = token.split("=", 1)
if _secret_key_like(key) and not _placeholder_secret_value(value):
return key

for index, token in enumerate(tokens[:-1]):
if not token.startswith("-"):
continue
key = token.lstrip("-").replace("-", "_")
value = tokens[index + 1]
if (
_secret_key_like(key)
and value
and not value.startswith("-")
and not _placeholder_secret_value(value)
):
return token
return None


def _redact_output(text: str) -> str:
if not text:
return text
Expand Down Expand Up @@ -469,6 +506,25 @@ def install_mcp(
),
)

command_tokens: list[str] | None = None
if effective_cmd:
try:
command_tokens = _split_install_command(effective_cmd)
except ValueError as exc:
return InstallResult(
slug=slug, status="invalid-cmd", command=effective_cmd,
message=f"could not parse --cmd/install_cmd: {exc}",
)
inline_secret_arg = _find_inline_secret_arg(command_tokens)
if inline_secret_arg is not None:
return InstallResult(
slug=slug, status="invalid-cmd", command=None,
message=(
f"--cmd/install_cmd argument {inline_secret_arg!r} looks like "
"an inline secret; pass an environment variable reference instead."
),
)

card = render_card(fm, slug, command=effective_cmd)
print(card)

Expand All @@ -489,13 +545,7 @@ def install_mcp(
rc, stdout, stderr = _run_claude_mcp(["add-json", slug, json_config])
else:
assert effective_cmd is not None # narrowed by dry_run branch above
try:
tokens = _split_install_command(effective_cmd)
except ValueError as exc:
return InstallResult(
slug=slug, status="invalid-cmd", command=effective_cmd,
message=f"could not parse --cmd/install_cmd: {exc}",
)
tokens = command_tokens if command_tokens is not None else _split_install_command(effective_cmd)
if not tokens:
return InstallResult(
slug=slug, status="invalid-cmd", command=effective_cmd,
Expand All @@ -505,7 +555,8 @@ def install_mcp(
# (which is under entity-file control); treat it as untrusted.
# Only known MCP-runtime launchers are allowed — if your
# server needs a bespoke runtime, add-json is the right path.
if tokens[0] not in _ALLOWED_CMD_EXECS:
executable = _normalized_executable(tokens[0])
if executable not in _ALLOWED_CMD_EXECS:
return InstallResult(
slug=slug, status="invalid-cmd", command=effective_cmd,
message=(
Expand Down
7 changes: 4 additions & 3 deletions src/ctx/adapters/claude_code/install/skill_unload.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@
from ctx.core.wiki.wiki_utils import validate_skill_name
from ctx.utils._file_lock import file_lock
from ctx.utils._fs_utils import atomic_write_text as _atomic_write_text
from ctx_config import cfg

CLAUDE_DIR = Path(os.path.expanduser("~/.claude"))
MANIFEST_PATH = CLAUDE_DIR / "skill-manifest.json"
CLAUDE_DIR = cfg.claude_dir
MANIFEST_PATH = cfg.skill_manifest
PENDING_UNLOAD = CLAUDE_DIR / "pending-unload.json"
WIKI_DIR = CLAUDE_DIR / "skill-wiki"
WIKI_DIR = cfg.wiki_dir
SKILL_ENTITIES = WIKI_DIR / "entities" / "skills"
AGENT_ENTITIES = WIKI_DIR / "entities" / "agents"

Expand Down
Loading
Loading