From 64d29e960e3b37059c8bb6b5dd0e046139726f11 Mon Sep 17 00:00:00 2001 From: RobLe3 Date: Tue, 25 Aug 2026 15:46:31 +0200 Subject: [PATCH 1/3] chore: bump version to 0.7.109 --- README.md | 17 +++++++++++++++-- pyproject.toml | 2 +- src/iicp_client/__init__.py | 2 +- uv.lock | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5db18b4..21bbd47 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ What good looks like: ```bash iicp-node --help # shows query, serve, proxy, mcp-gateway, credits, ... which iicp-node # points to your Python environment -iicp-node --version # prints iicp-node 0.7.108 or newer +iicp-node --version # prints iicp-node 0.7.109 or newer ``` The query command contacts the public directory, discovers a matching live node, @@ -213,7 +213,7 @@ base URL. Full guide: ## Keep provider nodes current -The current public release line is **0.7.108**. Upgrade through your package +The current public release line is **0.7.109**. Upgrade through your package manager before troubleshooting an older installation. Routing profiles can refuse remote dispatch before a prompt leaves the client; use `sensitive` for local-only work, `eu-restricted` for EU/EEA routing, or `strict-policy` when a @@ -720,3 +720,16 @@ ruff check src tests # lint --- Apache 2.0 · [iicp.network](https://iicp.network) + +### Shell completion + +Generate completion for your shell without reading node, operator, or network state: + +```bash +iicp-node completion bash +iicp-node completion zsh +iicp-node completion fish +iicp-node completion powershell +``` + +Evaluate the output in your shell startup file or redirect it to the shell's normal completion directory. diff --git a/pyproject.toml b/pyproject.toml index 773be08..6036103 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "iicp-client" -version = "0.7.108" +version = "0.7.109" description = "Use the open IICP AI mesh from Python without running a node" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/src/iicp_client/__init__.py b/src/iicp_client/__init__.py index 0ea20cc..d7f366e 100644 --- a/src/iicp_client/__init__.py +++ b/src/iicp_client/__init__.py @@ -149,7 +149,7 @@ TaskResponse, ) -__version__ = "0.7.108" +__version__ = "0.7.109" __all__ = [ "IicpClient", "IicpError", diff --git a/uv.lock b/uv.lock index 5155465..ffad58b 100644 --- a/uv.lock +++ b/uv.lock @@ -664,7 +664,7 @@ wheels = [ [[package]] name = "iicp-client" -version = "0.7.108" +version = "0.7.109" source = { editable = "." } dependencies = [ { name = "cryptography" }, From b1c2fa466630c2b7385e60572ad68400b14cbc53 Mon Sep 17 00:00:00 2001 From: RobLe3 Date: Tue, 25 Aug 2026 15:46:47 +0200 Subject: [PATCH 2/3] feat: add side-effect-free shell completion --- parity/cli-completion-v1.json | 26 +++++++++++++++++ src/iicp_client/cli.py | 14 ++++++++- src/iicp_client/completion.py | 53 +++++++++++++++++++++++++++++++++++ tests/test_cli_completion.py | 33 ++++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 parity/cli-completion-v1.json create mode 100644 src/iicp_client/completion.py create mode 100644 tests/test_cli_completion.py diff --git a/parity/cli-completion-v1.json b/parity/cli-completion-v1.json new file mode 100644 index 0000000..d20c615 --- /dev/null +++ b/parity/cli-completion-v1.json @@ -0,0 +1,26 @@ +{ + "schema": "iicp.cli-completion.v1", + "shells": ["bash", "zsh", "fish", "powershell"], + "aliases": {"pwsh": "powershell"}, + "common_top_level": [ + "completion", "credits", "doctor", "healthcheck", "help", "init", "list", + "mcp-gateway", "operator", "proxy", "query", "serve", "service", "update" + ], + "cases": [ + {"tokens": [], "contains": ["completion", "init", "query", "serve"]}, + {"tokens": ["op"], "contains": ["operator"]}, + {"tokens": ["operator", ""], "contains": ["decrypt", "dsr", "encrypt", "key", "rename"]}, + {"tokens": ["operator", "dsr", ""], "contains": ["anonymize", "export", "restrict"]}, + {"tokens": ["service", ""], "contains": ["install", "restart", "status", "uninstall"]}, + {"tokens": ["query", "--routing-profile", ""], "contains": ["eu-restricted", "sensitive", "standard", "strict-policy"]}, + {"tokens": ["serve", "--backend-type", ""], "contains": ["anthropic", "llamacpp", "meshllm", "openai_compat", "vllm"]}, + {"tokens": ["query", "--rou"], "contains": ["--routing-profile"]} + ], + "safety": { + "network_access": false, + "filesystem_mutation": false, + "shell_profile_mutation": false, + "operator_state_enumeration": false, + "secret_output": false + } +} diff --git a/src/iicp_client/cli.py b/src/iicp_client/cli.py index 633d9df..5b1ec67 100644 --- a/src/iicp_client/cli.py +++ b/src/iicp_client/cli.py @@ -211,6 +211,8 @@ def _build_parser() -> argparse.ArgumentParser: "help", help="Print this top-level usage and exit.", ) + completion = sub.add_parser("completion", help="Print a shell completion script.") + completion.add_argument("shell", choices=["bash", "zsh", "fish", "powershell", "pwsh"]) sub.add_parser( "init", help="Interactive wizard — set up operator identity + first node config.", @@ -3245,12 +3247,22 @@ def run_actions() -> None: def main(argv: list[str] | None = None) -> int: - parser = _build_parser() raw_argv = sys.argv[1:] if argv is None else argv + if raw_argv[:1] == ["__complete"]: + from iicp_client.completion import candidates + + sys.stdout.write("".join(f"{item}\n" for item in candidates(raw_argv[1:]))) + return 0 + parser = _build_parser() args = parser.parse_args(argv) if args.cmd == "help": parser.print_help() return 0 + if args.cmd == "completion": + from iicp_client.completion import script + + sys.stdout.write(script(args.shell)) + return 0 if args.cmd == "serve": # Record whether the operator explicitly toggled the NAT flag on the CLI # so saved-config restore can honour an explicit --no-auto-detect-nat diff --git a/src/iicp_client/completion.py b/src/iicp_client/completion.py new file mode 100644 index 0000000..b294b8f --- /dev/null +++ b/src/iicp_client/completion.py @@ -0,0 +1,53 @@ +"""Side-effect-free shell completion for the ``iicp-node`` CLI.""" + +from __future__ import annotations + +COMMANDS = ("completion", "credits", "doctor", "healthcheck", "help", "init", "list", "mcp-gateway", "operator", "proxy", "query", "serve", "service", "update") +SUBCOMMANDS = { + ("operator",): ("decrypt", "dsr", "encrypt", "key", "rename"), + ("operator", "dsr"): ("anonymize", "export", "restrict"), + ("operator", "key"): ("export", "generate", "import", "list", "revoke", "rotate"), + ("service",): ("install", "restart", "status", "uninstall"), +} +OPTIONS = { + (): ("--help", "--version"), + ("query",): ("--directory", "--intent", "--json", "--node", "--routing-profile"), + ("serve",): ("--backend-type", "--directory", "--host", "--node", "--port", "--routing-profile"), +} +VALUES = { + ("query", "--routing-profile"): ("eu-restricted", "sensitive", "standard", "strict-policy"), + ("serve", "--backend-type"): ("anthropic", "llamacpp", "meshllm", "openai_compat", "vllm"), +} + + +def candidates(tokens: list[str]) -> list[str]: + """Return static candidates without reading operator or network state.""" + if not tokens: + return list(COMMANDS) + partial = tokens[-1] + prior = tokens[:-1] + command = prior[0] if prior else "" + for (owner, option), values in VALUES.items(): + if command == owner and prior and prior[-1] == option: + return [value for value in values if value.startswith(partial)] + path = tuple(item for item in prior if not item.startswith("-") and item not in ("",)) + choices = list(SUBCOMMANDS.get(path, ())) + if not prior: + choices.extend(COMMANDS) + context = (command,) if command else () + if partial.startswith("-"): + choices = list(OPTIONS.get(context, ())) + list(OPTIONS[()]) + return sorted({choice for choice in choices if choice.startswith(partial)}) + + +def script(shell: str) -> str: + shell = "powershell" if shell == "pwsh" else shell + scripts = { + "bash": '''_iicp_node_complete() {\n COMPREPLY=()\n local -a args=("${COMP_WORDS[@]:1:$COMP_CWORD}")\n while IFS= read -r candidate; do COMPREPLY+=("$candidate"); done < <(command iicp-node __complete "${args[@]}")\n}\ncomplete -F _iicp_node_complete iicp-node\n''', + "zsh": '''_iicp_node_complete() {\n local -a args candidates\n args=("${words[@]:1}")\n candidates=("${(@f)$(command iicp-node __complete "${args[@]}")}")\n compadd -- $candidates\n}\ncompdef _iicp_node_complete iicp-node\n''', + "fish": '''function __iicp_node_complete\n set -l tokens (commandline -opc)\n set -e tokens[1]\n set -a tokens (commandline -ct)\n command iicp-node __complete $tokens\nend\ncomplete -c iicp-node -f -a '(__iicp_node_complete)'\n''', + "powershell": '''Register-ArgumentCompleter -Native -CommandName iicp-node -ScriptBlock {\n param($wordToComplete, $commandAst, $cursorPosition)\n $tokens = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.Extent.Text })\n if ($tokens.Count -eq 0 -or $commandAst.Extent.Text.EndsWith(' ')) { $tokens += '' }\n iicp-node __complete @tokens | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }\n}\n''', + } + if shell not in scripts: + raise ValueError(f"unsupported shell: {shell}") + return scripts[shell] diff --git a/tests/test_cli_completion.py b/tests/test_cli_completion.py new file mode 100644 index 0000000..7f69e65 --- /dev/null +++ b/tests/test_cli_completion.py @@ -0,0 +1,33 @@ +import json +from pathlib import Path + +import pytest + +from iicp_client.cli import main +from iicp_client.completion import candidates, script + + +FIXTURE = json.loads((Path(__file__).parents[1] / "parity/cli-completion-v1.json").read_text()) + + +@pytest.mark.parametrize("case", FIXTURE["cases"]) +def test_completion_parity(case): + result = candidates(case["tokens"]) + assert set(case["contains"]) <= set(result) + + +@pytest.mark.parametrize("shell", FIXTURE["shells"] + ["pwsh"]) +def test_scripts_are_static(shell): + rendered = script(shell) + assert "iicp-node __complete" in rendered + assert "IICP_DIRECTORY" not in rendered + + +def test_hidden_completion_stdout(capsys): + assert main(["__complete", "op"]) == 0 + assert capsys.readouterr().out == "operator\n" + + +def test_public_completion(capsys): + assert main(["completion", "bash"]) == 0 + assert "complete -F" in capsys.readouterr().out From 9bbcd559661b24c639c3df090b4161b97a73087b Mon Sep 17 00:00:00 2001 From: RobLe3 Date: Tue, 25 Aug 2026 15:51:11 +0200 Subject: [PATCH 3/3] style: normalize completion test imports --- tests/test_cli_completion.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_cli_completion.py b/tests/test_cli_completion.py index 7f69e65..d18e8c5 100644 --- a/tests/test_cli_completion.py +++ b/tests/test_cli_completion.py @@ -6,7 +6,6 @@ from iicp_client.cli import main from iicp_client.completion import candidates, script - FIXTURE = json.loads((Path(__file__).parents[1] / "parity/cli-completion-v1.json").read_text())