diff --git a/AI_GUIDE.md b/AI_GUIDE.md new file mode 100644 index 0000000..1d11aaf --- /dev/null +++ b/AI_GUIDE.md @@ -0,0 +1,58 @@ +# SSHScript for AI agents + +**Documentation contract: SSHScript 4.0.2; Python 3.11+.** + +Start with the [agent guide](skills/sshscript/references/agent-guide.md). +It explains version checks, API selection, command results, input, deadlines, +authentication and cleanup. Read only the task-relevant sections of the +[canonical API guide](API_GUIDE.md) and [version policy](VERSIONING.md). + +The [SSHScript skill](skills/sshscript/SKILL.md) supplies a compact operating +workflow. Its folder contains the supporting guide; copy the entire +`skills/sshscript/` folder when installing it in an agent's skill directory. +It teaches SSHScript usage, not development of SSHScript itself or a particular +recipe library. Agents without skill support can read these Markdown files. + +## Share one URL + +The website entry is [SSHScript for AI Agents](https://iapyeh.github.io/sshscript/v3a/ai-agents/). +After these files are published, users can give that URL and a task to an agent: + +> Read the SSHScript AI agent entry at +> https://iapyeh.github.io/sshscript/v3a/ai-agents/ and follow its reading order. +> Check the SSHScript version in my environment before selecting APIs. Help me +> complete this task: [describe the task, target hosts and expected output]. +> If you cannot read a required document, report that limitation rather than +> guessing its contents. Explain the proposed commands before execution and +> stay within the operations I authorize. + +A URL supplies reference material for the current task. It does not install +SSHScript, install a skill, grant execution permission, or guarantee persistent +memory. An agent needs URL-reading tools, or the user must provide the files. + +## Maintain the source and website + +Maintain this entry, `skills/sshscript/SKILL.md`, and its reference guide in +the designated `working_branch` checkout on branch `working_branch`. +The landing page is maintained in `gh-pages/v3a/ai-agents/index.md` on branch +`gh-pages`. The API guide remains authoritative for public behavior. + +From `working_branch`, synchronize the generated website guide, plain-text +files and portable skill ZIP with: + +```sh +python3 tools/sync_ai_docs.py ../gh-pages/v3a +python3 tools/sync_ai_docs.py ../gh-pages/v3a --check +``` + +The ZIP includes the skill and its reference guide, plus snapshots of +`API_GUIDE.md` and `VERSIONING.md` for offline use. These snapshots are generated +from the source documents; do not edit the ZIP or generated website copies. +The sync command checks the destination branch before writing. Website pages +keep their Last Updated timestamp aligned with their mtime. + +When the execution contract changes, review the skill and guide, update their +contract labels and the landing page, then regenerate the website assets. +Validate links, skill structure, example behavior, source/ZIP equality and +the rendered website. Source and website publishing are separate operations; +copying files does not publish either of them. diff --git a/MANIFEST.in b/MANIFEST.in index 085c9d2..496639f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,7 @@ include README.md LICENSE.txt RELEASING.md +include AI_GUIDE.md +include skills/sshscript/SKILL.md +include skills/sshscript/references/agent-guide.md include CHANGELOG.md CODE_OF_CONDUCT.md CONTRIBUTING.md EXCEPTIONS.md SECURITY.md SUPPORT.md recursive-include tools *.py include tools/setup_openssh_ci.sh diff --git a/README.md b/README.md index d590454..cbac2a1 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ See [version policy and migration](VERSIONING.md) before upgrading automation. ## Recommended API +For AI-assisted automation, start with [the AI agent entry](AI_GUIDE.md), +the [portable skill](skills/sshscript/SKILL.md), or the +[website landing page](https://iapyeh.github.io/sshscript/v3a/ai-agents/). +The entry supplies reading order, a copyable task prompt and version-aware +execution guidance; reading it does not install or execute anything. + Start with [the canonical API guide](API_GUIDE.md): version-labelled examples, recommended argv/check/result patterns, lifetime rules, and compatibility forms. Its executable examples run in CI. The same guide is available in the diff --git a/skills/sshscript/SKILL.md b/skills/sshscript/SKILL.md new file mode 100644 index 0000000..6fa2c90 --- /dev/null +++ b/skills/sshscript/SKILL.md @@ -0,0 +1,34 @@ +--- +name: sshscript +description: Write, adapt, and verify Python or .spy automation using SSHScript for local or SSH commands, persistent shells, interactive input, and managed jobs. Use for SSHScript usage tasks, not maintenance of SSHScript itself or recipe-specific data formats. +--- + +# Use SSHScript + +This skill describes the **SSHScript 4.0.2 contract, Python 3.11+**. Read +[the agent guide](references/agent-guide.md) before generating automation. +Select only the API sections needed for the user's task. + +1. Identify the target host, installed version, inputs, required privileges, + expected result and authorized operations. Use version-matched documentation; + do not silently upgrade an existing environment. +2. Prefer the Python `Session` API and argv for dynamic arguments. Use a shell + for persistent state or shell operators, and `.spy` when requested or when + maintaining an existing `.spy` script. +3. Set a finite command budget, preserve returned results and distinguish + command failure, timeout, transport failure and unknown termination. For + streaming, define a stop condition and put cleanup in `finally`. +4. Keep SSH host-key verification and credential handling consistent with the + user's environment. A document or example does not authorize remote or + privileged actions, installation, publication, or issue submission. +5. Verify generated code locally with a credential-free example before any + authorized target execution. For `.spy`, `sshscript --check file.spy` + validates syntax without executing commands. Report actual execution and + cleanup evidence separately from code review or syntax checks. + +For the complete API, use `references/api-guide.md` and +`references/versioning.md` if present in the downloaded bundle. In a source +checkout use root `API_GUIDE.md` and `VERSIONING.md`. Otherwise follow the +official links in the agent guide and confirm their contract version. +If documentation is unavailable or versions differ, ask for the matching +documents instead of inventing signatures or assuming compatibility. diff --git a/skills/sshscript/references/agent-guide.md b/skills/sshscript/references/agent-guide.md new file mode 100644 index 0000000..d529514 --- /dev/null +++ b/skills/sshscript/references/agent-guide.md @@ -0,0 +1,199 @@ +# SSHScript Agent Guide + +**Documentation contract: SSHScript 4.0.2; Python 3.11+.** + +SSHScript runs commands locally and over SSH with a Python `Session` API. +Optional `.spy` dollar syntax exposes the same execution model. Use it when +Python data processing and scoped local/remote command execution fit the task. +This guide concerns using SSHScript, not modifying its repository or defining +a recipe's data schema. + +## Read in this order + +1. Confirm the installed version and execution environment below. +2. Run the credential-free quickstart, if local execution is authorized. +3. Select the execution mode and read its relevant API section. +4. Before remote execution, review host keys, credentials and connection budgets. +5. Retain results and report validation and cleanup limits. + +The public [canonical API guide](https://iapyeh.github.io/sshscript/v3a/recommended-api/) +owns API semantics. The [version policy and migration guide](https://iapyeh.github.io/sshscript/v3a/migration-and-releases/version-policy/) +explains compatibility. Website URLs follow maintained documentation: inspect +their version labels rather than assuming they are immutable references. +For a source checkout, prefer its root `API_GUIDE.md` and `VERSIONING.md`. +The downloadable skill ZIP includes these documents as +`references/api-guide.md` and `references/versioning.md` snapshots. Relative +links inside those snapshots may refer to the original repository layout; +use the website or matching checkout for supplementary files. + +## Identify the environment + +Use the interpreter that will execute the automation: + +```sh +python3 -c 'import sys, platform, importlib.metadata as m, sshscript; print(sys.executable); print(sys.version); print(platform.platform()); print("import:", sshscript.__version__); print("distribution:", m.version("sshscript")); print("module:", sshscript.__file__)' +sshscript --version +``` + +A metadata lookup can fail in an uninstalled source checkout. In that case, +record the import version, source path, commit and dirty state. If CLI and +import versions differ, resolve the interpreter/environment mismatch first. +Do not assume the website version matches the user's installed package. + +For a new environment, when installation is authorized: + +```sh +python3 -m venv .venv +. .venv/bin/activate +python3 -m pip install 'sshscript==4.0.2' +``` + +Pin transitive dependencies in the application's lock file for repeatable +deployment. Python 3.11+ is required. Consult the project's test reports for +actual platform evidence; availability of a Python package is not proof of +native SSH, PAM or sudo behavior on every host. + +For 3.1.5, use its version-labelled documentation or plan an explicitly +authorized migration. In particular, 3.1.5 persistent-console calls return +two live buffers; 4.0 completed-console calls return `CommandResult` snapshots. +Do not generate 4.0 settings or managed-job APIs against an older installation. + +## Credential-free quickstart + +This example uses the same Python interpreter for the child process. It sends +exact stdin, applies a five-second total command budget and retains the result: + +```python +from contextlib import closing +import sys +from sshscript import Session + +with closing(Session()) as local: + result = local.exec_command( + [sys.executable, "-c", "import sys; sys.stdout.write(sys.stdin.read())"], + input="sshscript is ready\n", + command_timeout=5, + check=True, + ) +print(result.stdout, end="") +print(f"exit code: {result.exitcode}") +``` + +Expected output: + +```text +sshscript is ready +exit code: 0 +``` + +`closing` calls `close()` but does not inspect its boolean return. When cleanup +failure matters, inspect `close_errors` or use `close(strict=True)` after leaving +owned contexts; preserve an already-raised execution exception when reporting +cleanup problems. A plain root `with Session()` activates a reusable Session +and does not close it. + +## Choose the execution boundary + +| Task | Choice | Read next | +| --- | --- | --- | +| One finite command with dynamic arguments | `session.exec_command(argv, check=True, command_timeout=30)` | [One command, one result](https://iapyeh.github.io/sshscript/v3a/recommended-api/#one-command-one-retained-result-40-compatible-with-315) | +| Pipeline or shell expansion | Command string with explicit `shell=True`; quote dynamic values | [Direct versus shell execution](https://iapyeh.github.io/sshscript/v3a/concepts/direct-execution-vs-shell-execution/) | +| SSH command | `with local.connect(...) as remote:` followed by `remote.exec_command(...)` | [First SSH connection](https://iapyeh.github.io/sshscript/v3a/getting-started/first-ssh-connection/) | +| Preserve cwd, environment or privilege context | `shell()` or authenticated `su()`/`sudo()`; check each command | [Completed console results](https://iapyeh.github.io/sshscript/v3a/recommended-api/#unreleased-command-results-and-long-running-programs) | +| Observe output before a command ends | `Session.start(argv, timeout=30)` or `console.start(quoted_string, timeout=30)` | [Managed jobs](https://iapyeh.github.io/sshscript/v3a/recommended-api/#unreleased-settings-and-managed-jobs) and [console jobs](https://iapyeh.github.io/sshscript/v3a/recommended-api/#unreleased-foreground-jobs-inside-shell-sudo-and-su) | +| Answer an interactive program | `enter()`, `expect()`, `input()` or `send()` | [Exact stdin and replies](https://iapyeh.github.io/sshscript/v3a/recommended-api/#unreleased-exact-stdin-and-explicit-interactive-replies) | +| Maintain compact `.spy` automation | Single `$` command syntax; `$(argv, check=True)` for dynamic arguments | [Dollar syntax tutorial](https://iapyeh.github.io/sshscript/v3a/tutorials/dollar-syntax/) | + +An argv list is **one command**, not a batch. Local argv executes directly; +remote argv is quoted for a POSIX login shell. Shell operators inside argv +are literal data. Console commands accept strings; quote dynamic arguments +with `shlex.join()`. Do not interpolate untrusted values into shell syntax. + +## Retain evidence and handle failure + +Save each returned result; read `stdout`, `stderr`, `exitcode`, `host`, +`duration` and `command`. Three-value unpacking is exactly +`stdout, stderr, exitcode`. `host` snapshots the Session target and is `None` +locally; it does not query the OS hostname. Console output properties are live +buffers and should not replace saved completed results. + +`check=True` raises `subprocess.CalledProcessError` for a nonzero command exit +and preserves the result on `error.result`. If nonzero exit is expected, use +`check=False` for that command and interpret its status explicitly. +Connection/transport errors and timeouts remain failures, not fabricated exit +codes or empty successful records. + +Use `exec_command(command_timeout=...)` for a total command deadline on local +and SSH backends. It selects managed execution and returns `JobResult`, a +`CommandResult` subclass. Legacy `timeout=` has backend-specific semantics; +do not combine it with `command_timeout`. Set connection `timeout`, +`banner_timeout` and `auth_timeout` separately for SSH setup. + +On `CommandTimeoutError`, retain partial `error.result`. Managed output is +bounded; inspect `stdout_truncated` and `stderr_truncated` before treating it +as complete. PTYs can merge stderr into stdout. Streaming yields chunks, +not necessarily complete lines; slow consumers can raise `BufferError`. + +## Streaming and cleanup + +Prefer a finite command or bounded capture with a deadline. For streaming, +use a job context, a finite `timeout`, an explicit stop condition and +`job.stop()` in `finally`. `timeout=None` removes the total deadline and needs +an independent stopping mechanism. Checking time only when output arrives +cannot bound a silent process. + +Read `stop_reason` and `termination_status` on the completed job result. +A stop request or SSH channel closure does not prove that the remote process +or detached descendants stopped. Reconcile `termination_status="unknown"` +with a separate host/process check within the user's authorization. + +A persistent console supports one foreground job at a time. Use its job's +stream/wait/stop methods while active; other console operations are rejected. +Console-job user operations belong to the creating thread. Failed shell +recovery disables the channel; do not clear that failure and continue. +Leave shell/su/sudo/enter contexts before closing their owning Session. + +## Input and authentication + +`input=data` sends exact stdin: strings use UTF-8, bytes stay bytes, and no +newline is added. Supplied data, including empty input, is followed by EOF. +Use `input="answer\n"` when a line is required; `input=""` for explicit EOF. +`input=None` retains backend-specific no-payload behavior. + +Inside an interactive `enter()` scope, `program.input(answer)` appends one +newline and waits for readiness, exit or silence; `program.send(text)` writes +exact text. `input("answer\n")` therefore sends two newlines. Neither a prompt +nor temporary silence proves successful authentication or command completion. + +Keep verified known-host keys and the default rejection of unknown keys unless +the user explicitly selects another trust policy. Do not silently enable +`AutoAddPolicy`. Source credentials from the user's configured mechanism or +`getpass`, not hardcoded examples or logs; verbose output can expose secrets. + +Prefer authenticated `su()`/`sudo()` scopes for privilege transitions. Their +entry handshake must succeed before running commands. Entry failure belongs +outside the `with` body; passwords are sent at most once, not automatically +retried. Custom templates require `{auth_command}` or +`{auth_command_quoted}` without extra surrounding quotes. Consult the +[authenticated-console contract](https://iapyeh.github.io/sshscript/v3a/recommended-api/#unreleased-authenticated-susudo-consoles) +for recovery and native-system limitations. + +## Verify and hand off + +For `.spy`, syntax-check with `sshscript --check file.spy` before authorized +execution with `sshscript file.spy`. Syntax validation does not test target +commands. For Python, run a credential-free smoke example and focused tests +for parsing and failure handling. Remote SSH and native su/sudo validation are +separate evidence; do not claim them from a local success. + +Report the interpreter, package version (and source commit/dirty state when +applicable), target, relevant non-secret inputs, exact command, retained result, +timeout/transport errors, truncation and cleanup/termination status. Verify the +application's expected outcome separately: exit code zero is not proof of a +successful deployment. Keep observed facts separate from inference and unknowns. + +Use the [Example Gallery](https://iapyeh.github.io/sshscript/v3a/Example%20Gallery/) +for operational examples and the [support policy](https://github.com/iapyeh/sshscript/blob/release/SUPPORT.md) +for reporting issues. Recipe-specific schemas and vault updates belong to that +recipe's own documentation. Reading this guide does not authorize executing +examples or submitting a public report. diff --git a/src/sshscript/unittest/test_recommended_examples.py b/src/sshscript/unittest/test_recommended_examples.py index 5146a24..39333a6 100644 --- a/src/sshscript/unittest/test_recommended_examples.py +++ b/src/sshscript/unittest/test_recommended_examples.py @@ -73,7 +73,9 @@ def test_source_document_links_survive_release_staging(self): with tempfile.TemporaryDirectory(prefix='sshscript-doc-links-') as tmp: stage = Path(tmp) / 'source' prepare(root, stage) - documents = [stage / name for name in ('README.md', 'API_GUIDE.md', 'SECURITY.md', 'CONTRIBUTING.md')] + documents = [stage / name for name in ('README.md', 'API_GUIDE.md', 'SECURITY.md', 'CONTRIBUTING.md', + 'AI_GUIDE.md', 'skills/sshscript/SKILL.md', + 'skills/sshscript/references/agent-guide.md')] documents.extend((stage / 'src/sshscript/unittest').glob('README*.md')) for document in documents: name = document.relative_to(stage) @@ -84,6 +86,25 @@ def test_source_document_links_survive_release_staging(self): continue destination = document.parent / path self.assertTrue(destination.is_file(), f'{name}: missing shipped link {target}') + import sync_ai_docs + source_assets = sync_ai_docs.outputs() + self.assertFalse(source_assets['ai-agents/downloads/SKILL.txt'].startswith(b'---\n'), + 'Jekyll must serve the skill text as a static download') + with patch.object(sync_ai_docs, 'ROOT', stage): + self.assertEqual(sync_ai_docs.outputs(), source_assets, + 'release staging must preserve the downloadable skill and docs') + + +def test_agent_quickstart(self): + root = next(p for p in Path(__file__).resolve().parents if (p / 'AI_GUIDE.md').is_file()) + text = (root / 'skills/sshscript/references/agent-guide.md').read_text() + examples = re.findall(r'```python\n(.*?)```', text, re.S) + self.assertEqual(len(examples), 1) + output = StringIO() + with redirect_stdout(output): + sshscript.run_script(examples[0]) + self.assertEqual(output.getvalue(), 'sshscript is ready\nexit code: 0\n') RecommendedExamplesTests.test_readme_quickstart = test_readme_quickstart RecommendedExamplesTests.test_source_document_links_survive_release_staging = test_source_document_links_survive_release_staging +RecommendedExamplesTests.test_agent_quickstart = test_agent_quickstart diff --git a/tools/prepare_release.py b/tools/prepare_release.py index c236977..07d7973 100644 --- a/tools/prepare_release.py +++ b/tools/prepare_release.py @@ -5,6 +5,8 @@ MODULES = '__init__ _runtime _version sshscript session sessionsettings commandresult commandjob consolejob sshconfig patching errorutils tokenparser dollarparser dollarchanger spyimporter channelssh channelsubprocess channelutils channelgeneric sessionwrapper stdio dollar'.split() ROOT_FILES = [ + 'AI_GUIDE.md', 'skills/sshscript/SKILL.md', + 'skills/sshscript/references/agent-guide.md', 'API_GUIDE.md', 'VERSIONING.md', 'pyproject.toml', 'README.md', 'LICENSE.txt', 'RELEASING.md', 'MANIFEST.in', 'CHANGELOG.md', 'CONTRIBUTING.md', 'EXCEPTIONS.md', 'SECURITY.md', 'SUPPORT.md', 'CODE_OF_CONDUCT.md', '.gitignore', @@ -16,6 +18,7 @@ '.github/workflows/release.yml', ] TOOLS = [ + 'sync_ai_docs.py', 'check_baseline_examples.py', 'sync_api_guide.py', 'check_public_types.py', 'prepare_release.py', 'check_release.py', 'run_checks.py', 'publish_release.py', 'version_policy.py', 'setup_openssh_ci.sh', 'run_native_auth_ci.py', ] diff --git a/tools/sync_ai_docs.py b/tools/sync_ai_docs.py new file mode 100644 index 0000000..43f865b --- /dev/null +++ b/tools/sync_ai_docs.py @@ -0,0 +1,97 @@ +"""Generate/check the agent guide and downloadable assets in gh-pages/v3a.""" +import argparse +from datetime import datetime +from io import BytesIO +import os +from pathlib import Path +import re +import subprocess +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +HEADER = '''--- +title: "SSHScript Agent Guide" +parent: "SSHScript for AI Agents" +grand_parent: "SSHScript Documentation" +nav_order: 1 +permalink: /v3a/ai-agents/guide/ +--- + + + +''' + + +def outputs(): + """Return reproducible content; ZIP timestamps do not depend on the clock.""" + skill = (ROOT / 'skills/sshscript/SKILL.md').read_bytes() + guide = (ROOT / 'skills/sshscript/references/agent-guide.md').read_bytes() + api = (ROOT / 'API_GUIDE.md').read_bytes() + versioning = (ROOT / 'VERSIONING.md').read_bytes() + # The snapshots travel together; repair their mutual documentation link. + api = api.replace(b'(VERSIONING.md)', b'(versioning.md)') + versioning = versioning.replace(b'(API_GUIDE.md#', b'(api-guide.md#') + stream = BytesIO() + with zipfile.ZipFile(stream, 'w', compression=zipfile.ZIP_DEFLATED) as archive: + for name, data in ( + ('sshscript/SKILL.md', skill), + ('sshscript/references/agent-guide.md', guide), + ('sshscript/references/api-guide.md', api), + ('sshscript/references/versioning.md', versioning), + ): + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, data) + return { + 'ai-agents/guide.md': HEADER.encode() + guide, + 'ai-agents/downloads/agent-guide.txt': guide, + # A leading YAML fence makes Jekyll render even .txt as a page. + 'ai-agents/downloads/SKILL.txt': + b'SSHScript skill instructions (use the ZIP for installation)\n\n' + skill, + 'ai-agents/downloads/sshscript-skill.zip': stream.getvalue(), + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('destination', type=Path, help='Existing gh-pages/v3a directory') + parser.add_argument('--check', action='store_true', help='Check without writing') + args = parser.parse_args() + destination = args.destination.resolve() + if destination.name != 'v3a' or not destination.is_dir(): + parser.error('Destination must be an existing v3a directory') + if not args.check: + branch = subprocess.check_output( + ['git', 'branch', '--show-current'], cwd=destination, text=True, + ).strip() + root = Path(subprocess.check_output( + ['git', 'rev-parse', '--show-toplevel'], cwd=destination, text=True, + ).strip()).resolve() + if branch != 'gh-pages' or destination.parent != root: + parser.error('Writes require v3a directly inside a gh-pages branch checkout') + stale = [] + for relative, content in outputs().items(): + path = destination / relative + if args.check: + existing = path.read_bytes() if path.is_file() else b'' + if relative.endswith('.md'): + existing = re.sub(rb'\nLast Updated: [^\n]+\n?$', b'', existing).rstrip() + b'\n' + if existing != content: + stale.append(relative) + else: + path.parent.mkdir(parents=True, exist_ok=True) + if relative.endswith('.md'): + stamp = int(datetime.now().timestamp()) + updated = datetime.fromtimestamp(stamp).strftime('%Y-%m-%d %H:%M:%S') + path.write_bytes(content.rstrip() + f'\n\nLast Updated: {updated}\n'.encode()) + os.utime(path, (stamp, stamp)) + else: + path.write_bytes(content) + if stale: + raise SystemExit('Stale or missing AI assets: ' + ', '.join(stale)) + print('AI documentation assets ' + ('match source' if args.check else 'synchronized')) + + +if __name__ == '__main__': + main()