This file is for Claude Code, GitHub Copilot, OpenAI Codex, Kiro and any other assistant
working in this repository. It is the one to edit: AGENTS.md, and Kiro's steering files in
.kiro/steering/ (one per section), are generated from it (just ai), CLAUDE.md imports
it, and .github/copilot-instructions.md points here.
ldo (distribution libre-devops-helpers, import libre_devops_helpers): a fast, read-only
CLI and library for Microsoft (Entra ID, Defender XDR, Intune, Azure, Graph, PIM, Logic Apps,
Automation), ServiceNow, and Atlassian (Jira, Confluence), with helpers for Terraform
modules. Its users are people signing in as themselves, usually through the Azure CLI;
automation is second. Everything reads, apart from ldo az use, which switches the Azure
CLI's account, ldo planner add-news --write and add-rollup --write, which raise and
update Planner tasks, and ldo terraform sort and docs, which change a module's own files
(--check only reads). Never add a command that changes a tenant or an instance without
being asked.
- Run everything through
just(oruv run):just checkbefore calling anything done (ruff, the format check, strict mypy, and the tests with coverage),just cifor all of CI's checks,just fmtto format,just run <args>to try the CLI. - Python 3.11 or later: nothing newer than 3.11 in the code (
just test-311proves it). - ruff, line length 100. Match the surrounding code's naming, comment density and idiom.
- Runtime dependencies are
requestsandtyperonly. Everything else is the standard library. Do not add a runtime dependency without asking; test-only ones go in thedevgroup.
src/libre_devops_helpers/
core/ vendor-neutral: errors, config, brand, HTTP client, network (proxy
rules) and probe (testing the way out), trust (certificates), token
store, polling, inputs (CSV and Excel), logging, YAML writer,
sorting, colour
microsoft/ the shared Microsoft layer: clouds, credentials, tokens, profiles,
and the Graph and Resource Manager client bases (api_clients.py)
microsoft/<feature>/ one package per API: entra, xdr, graph, azure, pim, detections ...
microsoft/devices/ the one composite, using entra, xdr and intune
servicenow/ the ServiceNow vendor, same shape
atlassian/ the Atlassian vendor: jira/ and confluence/ on its shared layer
terraform/ Terraform modules as files: hcl (blocks), tools (fmt, terraform-docs),
and sort/ and docs/ on them. Local files only
cli/ the ldo command: parses, calls a client, renders. No logic here.
A command group too big for one file is a package (devices,
entra, logicapp, xdr) whose modules each register their own commands.
tests/project/test_package_surface.py enforces the layering: core imports nothing of
ours, a feature imports only core and its vendor layer, cli sits on top. Cross-cutting
code (auth, HTTP, input parsing) goes in core or the vendor layer, never in a feature. A new
feature package needs its entry in that test, a REQUIREMENTS tuple, and a test folder.
- Tests mirror
src(tests/core,tests/microsoft/<feature>,tests/cli/commands), one test module per module, andtests/project/test_layout.pychecks it. - Nothing touches the network, a real
azor a real clock. HTTP goes throughtests/fakes/http.py(routes,fake_session),azthroughfakes/azcli.py, time throughfakes/clock.py. Shared fakes live intests/fakes, one module per concern. - Coverage is gated at 93% (
fail_underinpyproject.toml). Keep new code covered. - CI runs with
GITHUB_ACTIONSset, which makes Typer colour and wrap errors: compare error text withusage_error(result), never a raw substring ofresult.output. - Windows caps an environment variable at 32,767 characters and pytest puts each test id in
one: give big parametrised values short
ids. tests/project/test_rebrand.pyrenames a copy of the repository and runs its whole suite.
The project can be renamed (just rebrand). Never hard-code ldo, LDO_ or the package
name in code: use core/brand.py (brand.COMMAND, brand.env_var("X"),
brand.command("config init"), brand.docs("page") for a link to a docs page).
-
Write for the next person to read it. No function past ruff's complexity limit of 10: split it into named steps. A public module, class or function gets a docstring saying what it gives (ruff checks); a comment says why, where the code cannot. Prefer a plain loop to a clever comprehension, and a named helper to a nested lambda.
-
A new API client subclasses
GraphServiceClientorArmServiceClient(microsoft/api_clients.py), which give itcreate,for_profile,closeandwith, orcore.http.ServiceClientfor any other API. A model reads its JSON throughcore.fields(text,mapping,items,flag,number,when), and an id that goes into a path throughcore.util.require_guid. -
Raise the specific error:
InputErrorfor something given that cannot be used,ConfigErrorfor a profile that cannot do this,NotFoundError,AmbiguousError,AuthError,ApiError;ValueErrorfor a library caller's own mistake. Never the bareLdoError(a test checks). -
-o jsonis a contract with scripts: snake_case keys, real booleans and numbers, andtests/project/test_json_output.pyrecords every command's shape. A change that is meant is recorded withLDO_RECORD_JSON_OUTPUT=1and goes in the changelog. -
mypy runs strictly on the package. Fix a type error rather than silencing it; a
castor an ignore needs a comment saying why the types are wrong and the code is right. -
Library code raises
LdoErrorsubclasses (InputError,NotFoundError,AmbiguousError,ApiError, ...) with ahintsaying what to do, and never exits. Only the CLI turns errors into messages and exit codes (0 fine, 1 error, 2 usage, 3 needs attention, 130 interrupted). -
Data goes to stdout; notes, warnings and progress to stderr, through
cli/render.py(note,warn,error,emit). In thejsonandotlplog formats those become log records, so never write to stderr directly. -
Every data command takes
-o table|json|csv|tsvand-p PROFILE; lists of names take arguments,-for stdin and-f FILEwith--column,--sheetand--where(core.row_filters). A list command also takessort: SortOption = None, unique: UniqueOption = Nonebeforeoutput, whichrender.emitapplies to its rows; a command showing one record does not. -
Colour is decided once, in
core/colour.py(the root's--colourflag,NO_COLOR,FORCE_COLOR, a terminal). Style withcolour.style, or a(text, colour)table cell; sort and de-duplicate withcore/sorting.py, neversorted()on display text. -
All HTTP goes through
core.http.ApiClient, which appliescore/network.py(the proxy andno_proxy) andcore/trust.py(the public roots, the OS store andca_bundle), andazruns with the same throughnetwork.subprocess_env(). Never callrequestsdirectly. -
Anything a person types that goes into a URL path or a query is validated first (names, ids, KQL values; a device name in KQL through
core.util.require_host), withre.fullmatch:^...$withmatchlets a trailing line break through. A token or secret is never printed (only with an explicit--raw), never logged, and never accepted on the command line. A proxy address can hold a password: show one only throughnetwork.redact(aRoute'sshown). -
Help text is rendered as markdown: no
<, no*, and paragraphs reflow. A test checks. -
Do not hide findings from a scanner or linter by renaming things; fix the code, or say it is a false positive and why.
README.mdis a short front page: the command table, install, quickstart and links. Detail goes indocs/, one page per area.CHANGELOG.mdgets anUnreleasedentry for every change a user would notice.tests/project/test_docs.pyruns everyldoexample in the README,docs/and this file with--help, checks everyjustexample is a recipe, and follows every link. Keep examples real.- UK English (colour, organisation, licence as a noun). Never use em or en dashes, in any file: use commas, colons, brackets or a plain hyphen.
- Do not commit, push, tag or release until the person asks. Use their own git identity.
- Every branch and tag is mirrored to GitLab (
gitlab-mirror.yml, seedocs/development.md). Never push to the GitLab copy: the next run overwrites it. Its.gitlab-ci.ymldoes what the GitHub workflows do; a change to one goes in the other, andtests/project/test_gitlab_ci.pykeeps their versions in step. - No AI attribution in commits or pull requests: no
Co-Authored-Byor "Generated with" lines. - Before a release, the person runs
ldo self-test(hidden) in a real tenant; a CRASH or usage row there is a bug to fix first. Rename anything from their tenant before it goes in a test. - A release is
just releaseafter the version is set inpyproject.tomlandsrc/libre_devops_helpers/__init__.pyandCHANGELOG.mdhas its section; seedocs/development.md. Never move or reuse a released tag, and remember a PyPI version can never be replaced.
.gitignorerules match anywhere unless anchored with/; ruff and hatchling honour.gitignore, so an ignored folder is silently left out of linting and the wheel.- The arm64 image is built under emulation: keep heavy work (such as compiling bytecode) in
a
FROM --platform=$BUILDPLATFORMstage. - The Azure CLI pins some dependencies exactly. A fix it holds back is forced in
container/azure-cli/pyproject.toml(see the comment there), then signed in with. - Microsoft APIs return stale duplicates (devices share names), paginate with
nextLinkor@odata.nextLink, and answer 403 for a missing scope, a missing licence and a suspended tenant alike: say which in the hint (Graph's own codes are inGRAPH_ERROR_HINTS,microsoft/api_clients.py). - Read an Azure resource id with
microsoft/resource_ids.py, never by splitting on/. A Log Analytics workspace's resource id and its Workspace ID (a GUID) are different things, and people mix them up:microsoft/workspaces.pytells them apart. - Two
ldocommands can run at once. Anything they both write (the token cache) is changed under a lock file, with a temporary file of its own renamed into place.