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
2 changes: 2 additions & 0 deletions src/kscli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
auth,
settings,
)
from kscli.commands.agent_help import agent_help
from kscli.commands.chunk_lineages import chunk_lineages
from kscli.commands.chunks import chunks
from kscli.commands.document_versions import document_versions
Expand Down Expand Up @@ -121,6 +122,7 @@ def main(ctx, format_, no_header, base_url): # noqa: ARG001 — params required
main.add_command(auth.logout)
main.add_command(auth.whoami)
main.add_command(settings.settings)
main.add_command(agent_help)

# ── Resource groups ─────────────────────────────────────────────────────────

Expand Down
176 changes: 176 additions & 0 deletions src/kscli/commands/agent_help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Compact CLI reference for AI agents — auto-generated from the Click command tree."""

from __future__ import annotations

import importlib.metadata
from typing import TYPE_CHECKING

import click

if TYPE_CHECKING:
from collections.abc import Iterator

COMMAND_CONSTRAINTS: dict[str, list[str]] = {
"folders list": [
"--folder-id and --parent-path-part-id are mutually exclusive",
"--show-content requires --folder-id",
"--max-depth is only valid with --show-content",
],
"folders bulk-ingest": [
"Exactly one of --folder-id or --path-part-id is required",
"--extensions must include at least one extension",
],
"chunks create": [
"Provide exactly one of --version-id or --section-id",
],
}

RECIPES = """\
── RECIPES ──

Ingest a file into a folder:
1. kscli -f json folders list # find target folder
2. kscli -f json documents ingest --file <path> --path-part-id <path_part_id>
3. kscli -f json workflows list # monitor ingestion

Search for chunks:
1. kscli -f json chunks search --query "…" # dense (vector) search
2. kscli -f json chunks search --query "…" --mode full_text # fallback to full-text
3. Add filters: --folder-ids <id> --document-ids <id> --tag-ids <id>

Browse folder structure:
1. kscli -f json folders list # list root folders
2. kscli -f json folders list --folder-id <id> --show-content # folder contents

Bulk-ingest a local directory:
1. kscli folders bulk-ingest <local_path> --folder-id <id> --dry-run
2. kscli folders bulk-ingest <local_path> --folder-id <id>
3. kscli -f json workflows list # monitor ingestion"""


def _compact_type(param: click.Parameter) -> str:
"""Map a Click parameter type to a compact string representation."""
if isinstance(param, click.Option) and param.is_flag:
return "flag"

ptype = param.type
suffix = "[]" if getattr(param, "multiple", False) else ""

if isinstance(ptype, click.Choice):
return "|".join(ptype.choices) + suffix
type_map: dict[click.ParamType, str] = {
click.STRING: "str",
click.INT: "int",
click.FLOAT: "float",
click.UUID: "UUID",
}
for click_type, label in type_map.items():
if ptype is click_type or isinstance(ptype, type(click_type)):
return label + suffix
if isinstance(ptype, click.Path):
return "path" + suffix
return str(ptype) + suffix


def _is_real_default(value: object) -> bool:
if value is None or value is False:
return False
s = str(value)
return "Sentinel" not in s and s not in ("()", "")


def _format_option(param: click.Option) -> str:
names = ", ".join(param.opts + param.secondary_opts)
typ = _compact_type(param)
parts = [f" {names} {typ}"]
if _is_real_default(param.default):
parts.append(f"={param.default}")
if param.required:
parts.append(" REQUIRED")
if param.help:
parts.append(f" {param.help}")
return "".join(parts)


def _format_argument(param: click.Argument) -> str:
typ = _compact_type(param)
return f"{param.human_readable_name}: {typ}"


def _walk_commands(group: click.Group) -> Iterator[tuple[str, click.Command]]:
"""Yield (group_name, command) for every subcommand, depth-first."""
for name in sorted(group.list_commands(click.Context(group))):
cmd = group.get_command(click.Context(group), name)
if cmd is None:
continue
if isinstance(cmd, click.Group):
for sub_name in sorted(cmd.list_commands(click.Context(cmd))):
sub_cmd = cmd.get_command(click.Context(cmd), sub_name)
if sub_cmd is not None:
yield name, sub_cmd
else:
yield "", cmd


def _build_output(root: click.Group) -> str:
lines: list[str] = []

version = importlib.metadata.version("kscli")
lines.append(f"kscli v{version}")
lines.append("")

# Global options
lines.append("GLOBAL OPTIONS")
for param in root.params:
if isinstance(param, click.Option) and param.name != "help":
lines.append(_format_option(param))
lines.append("")

current_group: str | None = None
for group_name, cmd in _walk_commands(root):
if cmd.name == "agent-help":
continue

if group_name and group_name != current_group:
current_group = group_name
lines.append(f"── {group_name} ──")
lines.append("")

# Command signature
args = " ".join(
f"<{_format_argument(p)}>"
for p in cmd.params
if isinstance(p, click.Argument)
)
sig = cmd.name or ""
if args:
sig = f"{sig} {args}"
help_text = (cmd.help or "").split("\n")[0]
lines.append(f"{sig} — {help_text}" if help_text else sig)

# Options
for param in cmd.params:
if isinstance(param, click.Option) and param.name != "help":
lines.append(_format_option(param))

# Constraints
cmd_path = f"{group_name} {cmd.name}" if group_name else (cmd.name or "")
if cmd_path in COMMAND_CONSTRAINTS:
lines.append(" constraints:")
for c in COMMAND_CONSTRAINTS[cmd_path]:
lines.append(f" - {c}")

lines.append("")

lines.append(RECIPES)
return "\n".join(lines)


@click.command("agent-help")
@click.pass_context
def agent_help(ctx: click.Context) -> None:
"""Print a compact CLI reference for AI agents."""
root = ctx.parent.command if ctx.parent else ctx.command
if not isinstance(root, click.Group):
raise click.ClickException("agent-help must be registered under a Group")
click.echo(_build_output(root))
77 changes: 77 additions & 0 deletions tests/test_agent_help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Unit tests for the agent-help command (Click CliRunner, no backend needed)."""

from click.testing import CliRunner

from kscli.cli import main
from kscli.commands.agent_help import COMMAND_CONSTRAINTS


class TestAgentHelp:
def setup_method(self) -> None:
self.runner = CliRunner()
self.result = self.runner.invoke(main, ["agent-help"])
self.output = self.result.output

def test_exits_successfully(self) -> None:
assert self.result.exit_code == 0

def test_version_header(self) -> None:
assert self.output.startswith("kscli v")

def test_global_options(self) -> None:
assert "GLOBAL OPTIONS" in self.output
assert "--format" in self.output
assert "--no-header" in self.output
assert "--base-url" in self.output

def test_resource_groups_present(self) -> None:
expected_groups = [
"folders",
"documents",
"document-versions",
"sections",
"chunks",
"tags",
"workflows",
"tenants",
"users",
"permissions",
"invites",
"threads",
"thread-messages",
"chunk-lineages",
"path-parts",
]
for group in expected_groups:
assert f"── {group} ──" in self.output, f"missing group: {group}"

def test_agent_help_excluded(self) -> None:
assert "agent-help" not in self.output

def test_folders_list_options(self) -> None:
assert "--parent-path-part-id" in self.output
assert "--show-content" in self.output
assert "--folder-id" in self.output

def test_chunks_search_options(self) -> None:
assert "--query" in self.output
assert "--search-type" in self.output

def test_constraints_folders_list(self) -> None:
for constraint in COMMAND_CONSTRAINTS["folders list"]:
assert constraint in self.output

def test_constraints_folders_bulk_ingest(self) -> None:
for constraint in COMMAND_CONSTRAINTS["folders bulk-ingest"]:
assert constraint in self.output

def test_constraints_chunks_create(self) -> None:
for constraint in COMMAND_CONSTRAINTS["chunks create"]:
assert constraint in self.output

def test_recipes_section(self) -> None:
assert "── RECIPES ──" in self.output
assert "Ingest a file into a folder" in self.output
assert "Search for chunks" in self.output
assert "Browse folder structure" in self.output
assert "Bulk-ingest a local directory" in self.output
Loading