--- name: writing-practitioner-guides description: Use when writing hands-on guides, runbooks, troubleshooting docs, lab walkthroughs, or validation procedures that a human will execute themselves in a terminal over SSH. Symptoms that this applies - the reader is expected to type the commands, the doc teaches how to read a system rather than reporting a result, or a draft contains python -c, embedded JSON parsing, one-liners nobody types by hand, or a config file or YAML authored with printf/echo >>/heredoc instead of an editor. --- # Writing Practitioner Guides ## Overview **Optimize for human operation, not command compactness.** A guide is not a script. The reader types each command, reads its raw output, and decides what to do next. Commands that are efficient for an agent to run once are often useless for a human learning to read a system. The failure this prevents: an agent writes `curl … | python3 -c 'import json…'` because it produces a clean answer in one call. The reader gets the answer and learns nothing about the tool they will need at 3am. ## The boundary Three tiers. Pick the lowest one that fits. ``` interactive command → short pipeline → saved script file ``` | Tier | When | Form | |---|---|---| | **interactive** | reading state, one question | `kubectl get pods`, `ss -lntp`, `journalctl -u nginx -n 50` | | **short pipeline** | filtering that a person would actually type | `ps aux \| grep java`, `… \| jq .status`, `for p in a b; do … done` | | **saved script** | it has become a program | write the file with an editor, then run it | **Move to a saved script when any of these is true:** - multiple branches (`if`) or nested loops - combining several requests and computing across them - non-trivial JSON transformation - you cannot tell what it does by reading it once - it will be run again later For a saved script, the guide says to open an editor, shows the code as a **separate file listing** (not a terminal command), then shows the run command. ```bash vim scripts/check_sessions.py ``` ```python # file: scripts/check_sessions.py ... ``` ```bash python3 scripts/check_sessions.py ``` Terminal command ≠ program source. Never blur them with a heredoc. ## Files the reader has to understand before changing them The tiers above stop at the saved script. The same split reaches further: **any file whose content the reader must read and understand to change it is written in an editor, not assembled by a shell one-liner.** Config files and YAML are that kind of file. > **조회·진단·실행은 CLI를 적극 사용하고, 사람이 내용을 이해하면서 작성해야 하는 설정 파일은 > 에디터를 사용한다.** | Reading or acting on the system → CLI | Authoring a file a human must understand → editor | |---|---| | CPU flags → `grep /proc/cpuinfo`, `lscpu` | cloud-init YAML → `nano kc-lab-1.yaml` | | service state → `systemctl status` | systemd unit → `sudo nano /etc/systemd/system/x.service` | | VM state → `virsh list --all` | `nginx.conf` → `sudo nano /etc/nginx/nginx.conf` | | network → `ip addr`, `virsh net-list --all` | `~/.bashrc` → `nano ~/.bashrc` | | logs → `journalctl -u x` | Kubernetes manifest → `nano deploy.yaml` | | fetch / copy → `curl`, `cp`, `scp` | a script → `nano x.sh` → `chmod +x x.sh` → `./x.sh` | | create a VM → `virt-install` | | ### This rule is not "replace sed with nano" > 단순히 **「`sed`를 `nano`로 바꿔라」**라고 하면 안 됩니다. 그러면 모든 shell 명령을 기계적으로 > 에디터 작업으로 바꿀 가능성이 큽니다. The trigger is the *file-authoring step*, not the appearance of a shell tool. | Kind | Examples | Verdict | |---|---|---| | what an operator types by hand | `virsh`, `systemctl`, `ssh`, `curl`, `virt-install` | keep | | reading / diagnosing | `grep`, `lsmod`, `cat`, `stat`, `groups` | keep | | shell tricks that author a file | `printf >`, `echo >>`, `cat < …'` | rewrite as an editor step | A pipe is not the problem. A 12-line `virt-install` is not the problem — creating the VM *is* that command's purpose, so the CLI is the right way to show it. `sed` is fine for a query or a throwaway substitution; it is wrong as the default interface for editing config, because what the reader will actually do during an incident is open the file and read what is in it. ### Rewrite: `~/.bashrc` ```bash # before echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc virsh uri ``` ```bash # after nano ~/.bashrc ``` ```text export LIBVIRT_DEFAULT_URI=qemu:///system ``` ```bash source ~/.bashrc virsh uri ``` Two reasons, and the second is the one that gets forgotten: 1. The reader sees the chain — file → variable → reload this shell → `virsh` now resolves that URI. `echo >>` produces only the end state. 2. **`echo >>` is not idempotent.** Someone who walks the guide a second time appends the same line again. Opening the file shows what is already there. ### Rewrite: cloud-init meta-data ```bash # before printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1 ``` ```bash # after nano meta-kc-lab-1 ``` ```yaml instance-id: kc-lab-1-20260912 local-hostname: kc-lab-1 ``` > `instance-id`는 이전 cloud-init 실행과 다른 인스턴스로 인식시키기 위해 이전 값과 겹치지 않게 > 지정한다. The `printf` form makes the reader decode `%s`, `\n`, `$(...)`, `date +%s` and `>` before reaching the two keys the page is about. A document teaching cloud-init should not be teaching shell `printf`. The editor form also gives the one line about `instance-id` a place to sit, right where it is typed. ### Rewrite: a file on a remote host ```bash # before ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml' ``` ```bash # after scp kc-lab-2.yaml donghyeon@192.168.122.11:~/ ssh donghyeon@192.168.122.11 ``` then, on the guest: ```bash chmod 600 ~/kc-lab-2.yaml cloud-init schema -c ~/kc-lab-2.yaml rm ~/kc-lab-2.yaml ``` The first line asked the reader to hold SSH, redirection, `umask`, file creation and local stdin at once. The rewrite uses more commands and puts fewer things in each: **one action, one command.** In a guide someone is learning from, that trade is the right way round. One more rewrite belongs to this rule — replacing a `python3 -c` YAML check with the format's own validator. It sits in **Command priority** below, where the ranking it changes lives. ## Command priority | Rank | Reach for | Examples | |---|---|---| | 1 | the system's own CLI | `kubectl`, `systemctl`, `psql`, `redis-cli`, `docker compose` | | 2 | standard OS tools | `ps`, `ss`, `lsof`, `free`, `top`, `dmesg` | | 3 | network / protocol tools | `curl`, `dig`, `openssl`, `nc`, `tcpdump` | | 4 | short Unix combinators | `grep`, `jq`, `awk`, `head`, `tail`, `less`, `watch` | | — | **avoid** | python/node heredocs, `python -c` that *processes data*, giant awk programs, pipelines built to produce one tidy answer | **The line is doing-the-work vs checking-a-fact, not the language.** ```bash # not fine — this is data processing the native tool should do curl -s "$URL" | python3 -c ' import json,sys for r in json.load(sys.stdin)["data"]["result"]: print(r["metric"]["pod"], r["value"][1])' ``` That one iterates, reshapes, and formats. `jq`, or the tool's own output flag, does that — and when neither is installed, say so and show the raw output instead of writing a parser. `grep`, `jq`, `awk`, and a one-line `for` are what practitioners type. Do not ban them. Ban the ones written for the *agent's* convenience. ### Validate with the format's own checker first Checking a fact is allowed — but a hand-rolled syntax check is the *last* resort, not the default. If the domain ships a command that validates this file, that command is the step. Drop to a one-line syntax check only when nothing validates the format. ```bash # before — checks that it parses as YAML, and nothing else python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")' # after — the domain's own validator: schema, keys, and deprecations too cloud-init schema -c ~/kc-lab-2.yaml ``` Same rank elsewhere: `nginx -t`, `sshd -t`, `systemd-analyze verify x.service`, `kubectl apply --dry-run=server -f deploy.yaml`, `terraform validate`, `docker compose config`. Each one is also the command the reader will reach for when the service refuses to start, which a `python3 -c` line never becomes. ### Two shapes of the same tool Most tools have a *reading* form and a *value-extracting* form. Pick by what the reader does next with the output. | The reader will… | Form | Example | |---|---|---| | **look at the response** and judge | reading form | `curl -I ` · `curl -v ` | | **compare or count the value** across runs or hosts | extracting form | `curl -s -o /dev/null -w '%{http_code}\n' ` | The extracting form hides everything except the field you chose. Use it only when that field *is* the answer — a status code you will compare before and after an injection, or a number you will repeat 900 times. When the reader is still figuring out what is wrong, they need the headers and the TLS handshake, not `200`. Same split elsewhere: `kubectl get` (read) vs `-o jsonpath=` (extract), `systemctl status` (read) vs `systemctl show -p X --value` (extract), `psql` interactive (read) vs `psql -tAc` (extract). **Show the reading form first at least once per tool.** A reader who has only ever seen `-w '%{http_code}'` cannot debug a TLS error. ## Progressive narrowing Never jump to the precise command. Show the widening-to-narrowing path the reader will actually walk. ``` list / status → detail / describe → logs → targeted inspection ``` ```bash kubectl get pods # what is there kubectl get pods -o wide # where, and on which node kubectl describe pod api-7c874-8m2js # why is this one unhappy kubectl logs api-7c874-8m2js # what did it say kubectl logs api-7c874-8m2js --previous # what did it say before it died ``` **Observe before filtering.** Show the raw output at least once before piping it. A reader who has never seen `ss -lntp` output needs to see it whole. ## Mutation ordering ``` observe → diagnose → reproduce → mutate ``` `rm`, `kill`, `delete`, `UPDATE`, restart, config change do not appear in the diagnosis phase. If a step changes state, say what it changes and how to undo it. ## Shape of one step Four elements, in this order. A command with no interpretation is not a step. ``` 무엇을 확인하는가 one line — the question this answers $ command the command, short 어디를 봐야 하는가 which field/line in the output matters 이 결과가 의미하는 것 what it tells you, and what to do next ``` Show the real output. If it was measured, quote it verbatim; if it is illustrative, say so. ### When the step changes state The four elements above are the shape of a step that **reads**. A step that **changes state** needs a different one — otherwise the why, the failure symptoms and the special cases all pile into the same paragraph: > 정보 밀도는 높은데 **처음 따라 하는 사람의 시선 이동이 어렵습니다.** ``` 목적 → 행동(번호 매긴 명령) → 예상 결과 → 왜 필요한가 → 문제가 생기면 ``` ```text ### 4. libvirt 기본 연결을 system으로 설정한다 목적 virsh가 사용자 세션이 아니라 시스템 libvirt에 연결되도록 한다. 1. 설정 파일을 연다. $ nano ~/.bashrc 2. 다음 줄을 추가한다. export LIBVIRT_DEFAULT_URI=qemu:///system 3. 저장한 설정을 현재 셸에 반영한다. $ source ~/.bashrc 4. 확인한다. $ virsh uri 예상 결과 qemu:///system 왜 필요한가 qemu:///session과 qemu:///system은 서로 다른 libvirt 연결이다. VM을 system 쪽에 만들고 virsh가 session 쪽을 보고 있으면 VM을 만들었는데도 목록에서 찾지 못할 수 있다. 문제가 생기면 $ virsh uri 부터 확인한다. ``` **A step that only reads takes the first shape; a step that changes state takes this one.** Same headings every time, so the reader's eye lands in the same place on step 11 as on step 1. ## Verify before publishing Every command in a guide must have been run, or be marked as unverified. **Check that the tools you reach for are actually installed on the machine the reader will be on** — `jq` and `yamllint` are absent more often than you expect, and a guide that assumes them sends the reader to install things mid-diagnosis. Two failures this catches, both real: - `kubectl get endpoints` — deprecated since v1.33, prints a warning - `kubectl exec keycloak-0 -- curl …` — the image has no curl, exit 127 A guide that teaches a stale or failing command makes the reader doubt their own environment. ## No placeholders `` puts the value outside the document. Give the command that produces it. For secrets, confirm existence or length — never print the value. ```bash TOKEN=$(ssh node1 'sudo cat /var/lib/rancher/k3s/server/node-token') echo "${#TOKEN} chars" ``` One exception: a value an earlier step already printed on screen, which the reader carries into a later step in a different shell — a session id, a realm UUID. The producing command is already in the document, so the value is not outside it. Write that slot as `{{NAME}}` (uppercase, digits, underscore), and say in the prose above the block which step printed it. Not `${NAME}` — guides use `$TOKEN`, `$SID` and friends as real shell variables, and a reader who reads a placeholder as one will paste it unchanged. `{{ }}` is not shell syntax, so pasting it fails where the reader can see it. ## The reader cannot see what is not on screen Everything below came out of reading 35 finished guides in one pass. Each rule names the accident that produced it. None of them is a style preference — in every case the guide passed every other rule in this file and still handed the reader a wrong answer that looked right. ### A value copied into a container is a snapshot, not a reference `kubectl run --env="K0=$K0"`, `docker run -e`, cloud-init user-data: each bakes the string as it was at that moment. From then on two spellings mean different things, and they differ by two characters: ```bash exec pod -- sh -c '...$K0...' # the value baked into the pod exec pod -- sh -c '...'"$K0"'...' # the value in the shell typing this ``` **They agree until something restarts.** In one guide a `rollout restart` changed the pod IP, and a later step still read the baked copy; the request went to an address that no longer existed, and the failure it produced was a `500` — which was also that step's expected result. The screen looked correct. So: when a step replaces the thing a baked value points at — a pod IP, a node address, a lease — say in that step which spelling the next commands use. Prefer recreating the container. If state inside it forbids that, show the live-read form and add one line saying why it differs from the block above. **Distance is not the test.** A variable defined 700 lines up can be safe, and one defined nine lines up can be stale. Ask what happened in between. ### A step that only works inside a window says when, not just what The four-part shape above — what / command / where to look / what it means — has no *when*. Several steps read correctly only during a restart, only while a rule is installed, only within sixty seconds. Read late, they do not error; they print a different, plausible number. Name the command that opens the window and the one that closes it, and say **what the reader sees if they type it after it closed** — that is what most readers will actually get. ### A check must be able to fail For every line that says "이렇게 나오면 통과", state what would print the same thing while the condition is false. Three real cases: - `virsh list --all` used to prove a group membership took effect — it connects to the per-user URI, which works with no group at all - a `postmaster.pid` path checked on a fixed node, when the volume may be on the other one — "no such file" passes either way - `source ~/.bashrc` on a host whose login shell is zsh — true in that shell, gone at next login, and the symptom surfaces one guide later If the check passes when the step was skipped — because it reads a different scope, host or shell than the step wrote to — it is not a check. Put the scope in the command, or write one line naming the failure this check cannot see. ### Never ship a command the guide knows is wrong Three guides printed a command and told the reader, in prose underneath, to edit it before typing. Prose is not a guard. The worst of them was a **valid** assignment: ```bash OLDID=980ee9b7-... # ← copy from the output above ``` Paste it and `OLDID` holds the literal string, and the `delete` two lines later runs. Every other hardcoded value in those guides failed loudly — `(0 rows)`, `(nil)`. This one succeeded at the wrong thing. If prose under a block says "replace X first", the block already says it — or says `{{X}}`, which is not shell syntax and fails where the reader can see it. ### One block, one machine A code block is the reader's copy unit. When the machine changes, the block ends, even when the commands form one logical step. Splitting the explanation in prose does not help: they copy the block. And a machine label tells the reader *where* a block runs — it does not put them there. When consecutive blocks carry different labels, the transition is its own step with its own command (`ssh host`, `exit`, "open a second terminal"). Count them per guide: entries and exits must balance. A verification section that runs somewhere else needs both. ### A shell function defined mid-guide is worse than all three tiers It leaves no file, so the reader cannot re-read it. It dies with the shell, so a new window silently breaks every later step. `unset` does not reach it. One guide defined `R()` on line 188 and used it through line 617 — a one-letter name for `kubectl -n … exec deploy/redis -- redis-cli`. At the point of use the reader cannot see what command they are running. If something is repeated often enough to want a name, make it a script file with that name. If it must be a function, define it in the same block as its first use, name it for what it does (`redis`, not `R`), and restate the definition at the top of any later section more than a screen away. ### Names the reader must already have A host alias, a directory, a volume, a file that no earlier step created is not a placeholder — it is an assumption, and the placeholder rule above does not catch it. Four guides in one set ran entirely on `ssh kc-lab-1` with no stanza anywhere that creates it; the second guide hits it on its first command. Show the command that creates it, or name the guide that owns it, in the prose above the first block that uses it. ### Walk the guide twice `echo >>` is one case of a larger rule. Anything created under a fixed name — a pod, a volume, a DHCP reservation, a namespace — says what the second run prints and how to clear it first. `--rm` only removes on clean exit; Ctrl+C leaves the pod and the next run dies on `AlreadyExists`. And anything that destroys and recreates a host says what identity changes with it — SSH host keys, MAC-bound leases, certificates — and what that breaks the next time the reader connects. ### The output block belongs to the command above it If it came from a wider `grep`, an extra `uniq -c`, or a `sed` that rewrote names, show that command too or say so on the line before. Readers compare their screen to yours character by character; a silent edit makes them hunt for a fault that is not there. ### Context selectors are read as a set `-n`, `--context`, `-h`, `-U`. One guide's `kubectl exec` was missing `-n keycloak-lab` while the eight commands around it had it. On its own the line looks fine; next to its neighbours it is obviously running somewhere else. Read them as a column, not line by line. ### When the same action has several forms, one of them is the step One guide gave three ways to kill a backend and marked none of them. Show the step; put the rest under a heading that says they are alternatives. Leaving the reader to choose asks them to weigh a trade-off the guide has not explained yet. ### A fixed output path makes a block single-use "Change the variable and run it again" is incomplete when the block writes to a fixed file: the second run overwrites the first run's baseline, silently, and the comparison two sections later has nothing to compare against. Name the output path among the things to change. ### Run the remedy you prescribe A guide diagnosed lexical sorting and prescribed `sort -g`. Every line in that file began with the same `200`, so `sort -g` compared equal and fell through to byte order — its output was identical to plain `sort`. The fix the guide offered did not fix anything, and the sentence "miss this and you misread the maximum" stayed true after following it. ## Rationalization table | Excuse | Reality | |---|---| | "The pipeline gives a clean answer" | The reader needs to read the raw output, not your summary of it | | "python -c is shorter than explaining" | It is shorter for you. The reader learns nothing and cannot adapt it | | "jq/awk are also programming" | They are what practitioners type. The line is *program vs command*, not *language* | | "I'll show the efficient way" | Efficient for one run. This doc is for someone learning to read the system | | "The reader can copy-paste it" | Copy-paste is not the goal. Knowing where to look is | | "I verified the logic mentally" | Run it. Two commands in a recent guide were wrong and both looked right | | "It's obvious what this output means" | Then write the one line. If it is obvious it costs nothing | | "`-w '%{http_code}'` is precise" | Precise about one field. The reader debugging TLS needs `-v`, not `200` | | "It's fewer commands" | Fewer for you to type. The reader cannot tell which action they are in the middle of | | "The file ends up the same either way" | Only on the first run. `echo >>` appends again every time someone repeats the guide | | "So I should use nano for everything" | No. The trigger is authoring a file the reader must understand. `grep`, `virsh`, a long `virt-install` stay as they are | | "`python3 -c` only checks syntax" | Then it misses the schema. If the format has a validator — `cloud-init schema`, `nginx -t`, `--dry-run` — that is the step | | "The prose right under it says to change that value" | The reader copies the block. Prose is not a guard — put `{{NAME}}` in the block | | "The variable is defined earlier in the same shell" | Ask what restarted in between. A pod IP baked at line 877 was stale by line 764 | | "The label says which machine it runs on" | A label says where, not how to get there. The transition is its own step | | "It's the same command, just shorter" | A one-letter function hides the command at the exact moment the reader needs to read it | | "The check passed" | Ask what it would print if the step had been skipped. Three checks in one set passed either way | ## Red flags — stop and rewrite - `python3 -c` or a `<<'PY'` heredoc inside a guide - JSON parsed with a language runtime instead of `jq` or the tool's own `-o` - a pipeline whose purpose you cannot state in one clause - a command with no "what to look at" line under it - `delete`/`kill`/`restart` before any observation step - `` with no command that produces it - output shown that you never actually ran - only the extracting form of a tool appears, never the reading form - a config file or YAML built with `printf >`, `echo >>`, or `cat < f' < f`) - `python3 -c` checking syntax when the format has its own validator - a step that appends, so walking the guide twice appends the line twice - prose under a block telling the reader to edit the command before typing it - a block whose machine label differs from the one above, with no command between - a one- or two-letter shell function, or any function defined far from its use - a host alias, directory or volume that no step in any guide creates - `--rm` with no line saying what a Ctrl+C leaves behind - a "통과" line that would print the same thing if the step had been skipped - a step that reads correctly only during a window, with no word about the window - a remedy you have not run against the data that made you prescribe it ## References Per-technology command vocabulary — what practitioners reach for first, not an encyclopedia. Load only the one you need. - [kubernetes.md](references/kubernetes.md) - [linux-systemd.md](references/linux-systemd.md) - [networking-tls.md](references/networking-tls.md) - [datastores.md](references/datastores.md)