agentirc-cli 9.3.0__tar.gz → 9.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/CHANGELOG.md +60 -0
  2. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/CLAUDE.md +13 -8
  3. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/PKG-INFO +2 -1
  4. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/cli.py +178 -71
  5. agentirc_cli-9.4.0/agentirc/config.py +126 -0
  6. agentirc_cli-9.4.0/docs/api-stability.md +232 -0
  7. agentirc_cli-9.4.0/docs/cli.md +267 -0
  8. agentirc_cli-9.4.0/docs/deployment.md +233 -0
  9. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/docs/superpowers/specs/2026-04-30-bootstrap-design.md +2 -1
  10. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/pyproject.toml +5 -4
  11. agentirc_cli-9.4.0/tests/test_config_loader.py +251 -0
  12. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/uv.lock +3 -1
  13. agentirc_cli-9.3.0/agentirc/config.py +0 -49
  14. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.claude/skills/pr-review/SKILL.md +0 -0
  15. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.claude/skills/pr-review/scripts/portability-lint.sh +0 -0
  16. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.claude/skills/pr-review/scripts/pr-batch.sh +0 -0
  17. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.claude/skills/pr-review/scripts/pr-comments.sh +0 -0
  18. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.claude/skills/pr-review/scripts/pr-reply.sh +0 -0
  19. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.claude/skills/pr-review/scripts/pr-sonar.sh +0 -0
  20. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.claude/skills/pr-review/scripts/pr-status.sh +0 -0
  21. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.claude/skills/pr-review/scripts/workflow.sh +0 -0
  22. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.claude/skills.local.yaml.example +0 -0
  23. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.github/workflows/publish.yml +0 -0
  24. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.github/workflows/tests.yml +0 -0
  25. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/.gitignore +0 -0
  26. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/LICENSE +0 -0
  27. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/README.md +0 -0
  28. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/__init__.py +0 -0
  29. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/__main__.py +0 -0
  30. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/__init__.py +0 -0
  31. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/aio.py +0 -0
  32. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/bots/__init__.py +0 -0
  33. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/bots/bot_manager.py +0 -0
  34. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/bots/http_listener.py +0 -0
  35. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/cli_shared/__init__.py +0 -0
  36. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/cli_shared/constants.py +0 -0
  37. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/cli_shared/mesh.py +0 -0
  38. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/constants.py +0 -0
  39. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/pidfile.py +0 -0
  40. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/protocol/__init__.py +0 -0
  41. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/protocol/message.py +0 -0
  42. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/protocol/replies.py +0 -0
  43. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/telemetry/__init__.py +0 -0
  44. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/telemetry/audit.py +0 -0
  45. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/telemetry/context.py +0 -0
  46. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/telemetry/metrics.py +0 -0
  47. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/telemetry/tracing.py +0 -0
  48. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/_internal/virtual_client.py +0 -0
  49. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/channel.py +0 -0
  50. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/client.py +0 -0
  51. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/events.py +0 -0
  52. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/history_store.py +0 -0
  53. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/ircd.py +0 -0
  54. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/protocol.py +0 -0
  55. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/remote_client.py +0 -0
  56. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/room_store.py +0 -0
  57. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/rooms_util.py +0 -0
  58. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/server_link.py +0 -0
  59. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/skill.py +0 -0
  60. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/skills/__init__.py +0 -0
  61. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/skills/history.py +0 -0
  62. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/skills/icon.py +0 -0
  63. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/skills/rooms.py +0 -0
  64. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/skills/threads.py +0 -0
  65. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/agentirc/thread_store.py +0 -0
  66. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/docs/steward/onboarding.md +0 -0
  67. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/__init__.py +0 -0
  68. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/_helpers.py +0 -0
  69. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/conftest.py +0 -0
  70. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/__init__.py +0 -0
  71. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/_fakes.py +0 -0
  72. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/_metrics_helpers.py +0 -0
  73. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_audit_emit.py +0 -0
  74. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_audit_lifecycle.py +0 -0
  75. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_audit_module.py +0 -0
  76. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_audit_parse_error.py +0 -0
  77. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_config.py +0 -0
  78. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_dispatch_span.py +0 -0
  79. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_emit_event_span.py +0 -0
  80. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_metrics_init.py +0 -0
  81. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_metrics_s2s.py +0 -0
  82. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_outbound_inject.py +0 -0
  83. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_parse_error.py +0 -0
  84. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_s2s_relay_span.py +0 -0
  85. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_server_init.py +0 -0
  86. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_server_link_inject.py +0 -0
  87. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/telemetry/test_tracing.py +0 -0
  88. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_channel.py +0 -0
  89. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_cli.py +0 -0
  90. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_connection.py +0 -0
  91. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_discovery.py +0 -0
  92. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_events_basic.py +0 -0
  93. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_events_catalog.py +0 -0
  94. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_events_federation.py +0 -0
  95. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_events_history.py +0 -0
  96. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_events_lifecycle.py +0 -0
  97. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_events_reserved_nick.py +0 -0
  98. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_federation.py +0 -0
  99. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_history.py +0 -0
  100. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_link_reconnect.py +0 -0
  101. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_mentions.py +0 -0
  102. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_messaging.py +0 -0
  103. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_modes.py +0 -0
  104. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_room_persistence.py +0 -0
  105. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_rooms_federation.py +0 -0
  106. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_rooms_integration.py +0 -0
  107. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_server_icon_skill.py +0 -0
  108. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_skills.py +0 -0
  109. {agentirc_cli-9.3.0 → agentirc_cli-9.4.0}/tests/test_threads.py +0 -0
@@ -4,6 +4,66 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [9.4.0] - 2026-05-01
8
+
9
+ ### Added
10
+
11
+ - `ServerConfig.from_yaml(path)` — public classmethod on
12
+ `agentirc.config.ServerConfig` that loads `~/.culture/server.yaml`
13
+ (or any YAML file). Recognises `server`, `telemetry`, `links`,
14
+ `webhook_port`, `data_dir`, `system_bots` keys; silently ignores
15
+ culture-only top-level keys (`supervisor`, `agents`, `buffer_size`,
16
+ `poll_interval`, `sleep_start`, `sleep_end`, `webhooks`) so the same
17
+ file can drive both `culture server` and `agentirc` daemons.
18
+ - CLI handlers (`serve`, `start`, `restart`) now overlay CLI flags on
19
+ top of YAML config. Precedence: explicit CLI flag (sentinel-`None`
20
+ detection) > YAML key > built-in default. Closes the bootstrap
21
+ acceptance criterion *"`agentirc start --config <path>` behaves
22
+ indistinguishably from `culture server start`"*.
23
+ - New runtime dependency: `pyyaml>=6.0`.
24
+ - `docs/api-stability.md` — semver contract for the three public
25
+ modules (`agentirc.config`, `agentirc.cli`, `agentirc.protocol`),
26
+ full member list per module, internal-may-refactor list, wire-format
27
+ quirks paragraph, versioning history.
28
+ - `docs/cli.md` — verb table (8 verbs), per-verb flag reference, exit
29
+ codes, stderr formatting, YAML/CLI precedence, and the agentirc-vs-
30
+ culture differences table.
31
+ - `docs/deployment.md` — on-disk footprint, systemd `Type=simple`
32
+ example unit, container deployment, standalone deployment,
33
+ multi-host federation, log rotation (logrotate `copytruncate` rule),
34
+ coexistence with culture, backup recommendations.
35
+
36
+ ### Changed
37
+
38
+ - `agentirc/cli.py:_add_start_flags` — argparse defaults for `--name`,
39
+ `--host`, `--port`, `--link`, `--webhook-port`, `--data-dir` are now
40
+ sentinel `None` rather than concrete values. The user-visible
41
+ defaults (`0.0.0.0`, `6667`, `7680`, `~/.culture/data`) are still
42
+ documented in help strings and live on the `ServerConfig` dataclass;
43
+ the change lets `_resolve_config` distinguish "user-supplied" from
44
+ "argparse-filled" when overlaying CLI on YAML.
45
+
46
+ ### Removed
47
+
48
+ - `agentirc/cli.py:_maybe_warn_unused_config` — the
49
+ *"`--config` was supplied, but YAML config loading is not yet wired
50
+ (PR-B4)"* warning that PR #4 review (Qodo + Copilot) requested. YAML
51
+ loading is now wired; the warning was the placeholder for this PR.
52
+
53
+ ### Notes
54
+
55
+ - Bootstrap is functionally + docs complete as of 9.4.0. Remaining
56
+ work per the spec status note: acceptance-criteria spot-check (Task
57
+ 14) and release ceremony (Tasks 16–18 — tag, publish, report-back to
58
+ culture). The cross-repo wire-format fixes (Track A) and steward
59
+ backport of `pr-sonar.sh` are the only outstanding follow-ups.
60
+ - 13 new tests in `tests/test_config_loader.py` exercise
61
+ `ServerConfig.from_yaml` (missing/empty/malformed files, unknown-key
62
+ tolerance, telemetry/links sections) and the `_resolve_config` merge
63
+ (CLI overrides YAML, YAML used when CLI absent, defaults on both
64
+ absent, links wholesale-replace). Total suite: 328 tests, ~28s under
65
+ `pytest -n auto`.
66
+
7
67
  ## [9.3.0] - 2026-05-01
8
68
 
9
69
  ### Added
@@ -2,21 +2,26 @@
2
2
 
3
3
  This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
4
 
5
- ## Current state: bootstrap functionally complete (9.3.0); docs slice (PR-B4) remains
5
+ ## Current state: bootstrap functionally + docs complete (9.4.0); release ceremony remains
6
6
 
7
- This repo is the agentirc server-core extraction out of the sibling project [`culture`](https://github.com/OriNachum/culture). As of 9.3.0:
7
+ This repo is the agentirc server-core extraction out of the sibling project [`culture`](https://github.com/OriNachum/culture). As of 9.4.0:
8
8
 
9
9
  - **Server-core** (`agentirc/{ircd,server_link,channel,events,skill,remote_client,…}.py`, `agentirc/skills/{rooms,threads,history,icon}.py`) — vendored from `culture@df50942` via the `cite-don't-copy` pattern (see `[tool.citation]` in `pyproject.toml`).
10
- - **Client transport** (`agentirc/client.py`) — vendored from `culture/agentirc/client.py` in PR-B2. The bootstrap spec originally said this would "stay in culture", but the dependency-boundary analysis after PR-B1 showed `client.py` only imports already-vendored support modules plus opentelemetry. Without it, `agentirc/ircd.py:580`'s runtime `from agentirc.client import Client` raised `ImportError` on the first TCP IRC connection.
11
- - **Public CLI** (`agentirc/cli.py`) — real verb dispatch extracted from `culture/cli/server.py`. Verbs: `serve` (foreground, no PID; for systemd `Type=simple` and containers), `start`/`stop`/`status` (lifecycle), `restart`, `link` (peer-spec validator), `logs` (cat / tail of `~/.culture/logs/server-<name>.log`), `version`.
10
+ - **Client transport** (`agentirc/client.py`) — vendored from `culture/agentirc/client.py` in PR-B2.
11
+ - **Public CLI** (`agentirc/cli.py`) — real verb dispatch extracted from `culture/cli/server.py`. Verbs: `serve` (foreground, no PID; for systemd `Type=simple` and containers), `start`/`stop`/`status` (lifecycle), `restart`, `link` (peer-spec validator), `logs` (cat / tail of `~/.culture/logs/server-<name>.log`), `version`. Since 9.4.0, `serve`/`start`/`restart` overlay CLI flags on `--config` YAML (precedence: CLI > YAML > built-in default).
12
+ - **Public config** (`agentirc/config.py`) — `ServerConfig`, `LinkConfig`, `TelemetryConfig` dataclasses plus the `ServerConfig.from_yaml(path)` classmethod (added 9.4.0). Recognises `server`/`telemetry`/`links`/`webhook_port`/`data_dir`/`system_bots` keys; silently ignores culture-only keys (`supervisor`, `agents`, `buffer_size`, etc.) so the same `~/.culture/server.yaml` can drive both daemons.
12
13
  - **Public protocol** (`agentirc/protocol.py`) — verb name constants, numerics, IRCv3 tag names. Wire-format quirks (`ROOMETAEND`, `ROOMETASET` typos, `ERR_NOSUCHCHANNEL` semantic misuse, `STHREAD` verb collapse) preserved verbatim — they need coordinated cross-repo bumps to fix.
13
- - **Test suite** (PR-B3, 9.3.0) — 36 tests vendored from `culture@df50942` (~6.5kloc), 315 tests run under `pytest -n auto` in ~29s on default workers. Three telemetry tests (`test_bot_event_dispatch_span`, `test_bot_run_span`, `test_metrics_bots`) and `test_welcome_bot` stay in culture because they depend on the real `BotManager`. `tests/conftest.py` was adapted to drop bot-loader sandboxing and the `server_with_bot` / `server_with_bots` fixtures.
14
- - **Internal support** (`agentirc/_internal/`) — `aio`, `constants`, `protocol/`, `telemetry/`, `virtual_client`, `pidfile` (PR-B2), `cli_shared/` (PR-B2), `bots/` stubs.
14
+ - **Test suite** (PR-B3, 9.3.0; +13 in 9.4.0) — 36 tests vendored from `culture@df50942` (~6.5kloc) plus 13 new agentirc-native tests in `tests/test_config_loader.py`. 328 tests run under `pytest -n auto` in ~28s on default workers. Three telemetry tests (`test_bot_event_dispatch_span`, `test_bot_run_span`, `test_metrics_bots`) and `test_welcome_bot` stay in culture because they depend on the real `BotManager`.
15
+ - **Internal support** (`agentirc/_internal/`) — `aio`, `constants`, `protocol/`, `telemetry/`, `virtual_client`, `pidfile`, `cli_shared/`, `bots/` stubs.
16
+ - **Bootstrap docs** (PR-B4, 9.4.0) — `docs/api-stability.md` (3 public modules + semver contract), `docs/cli.md` (verb table, flag reference, exit codes, YAML/CLI precedence, agentirc-vs-culture diff table), `docs/deployment.md` (on-disk footprint, systemd `Type=simple` example, container deployment, multi-host federation, log rotation, coexistence with culture, backup).
15
17
 
16
- End-to-end verified: `agentirc start --port <p>` boots a real IRCd, TCP NICK/USER handshake returns `001 RPL_WELCOME`, `agentirc stop` shuts cleanly. `agentirc serve` is byte-indistinguishable from `culture server start` for the lifecycle contract culture's shim relies on.
18
+ End-to-end verified: `agentirc start --port <p>` boots a real IRCd, TCP NICK/USER handshake returns `001 RPL_WELCOME`, `agentirc stop` shuts cleanly. `agentirc serve --config server.yaml --port 9999` correctly overlays CLI flag on YAML.
17
19
 
18
20
  What is **not** done yet:
19
- - **Bootstrap docs (PR-B4)** `docs/api-stability.md`, `docs/cli.md`, `docs/deployment.md`. Pure prose; not gating on culture's cutover (the public API surface is already importable; PR-B4 just documents the contract).
21
+ - **Acceptance-criteria spot-check** (Task 14 in the bootstrap spec) read-only audit to confirm every bullet in §"Acceptance criteria" is before tagging.
22
+ - **Release ceremony** (Tasks 16–18) — tag `v9.4.0`, the existing `publish.yml` CI pushes to PyPI on push to `main`, then report version + source SHA back so culture's cutover PR can pin against it.
23
+ - **Cross-repo wire-format fixes (Track A)** — `ROOMETAEND`/`ROOMETASET` typos, `ERR_NOSUCHCHANNEL` overload, `STHREAD` collapse. Each requires culture-side change first then agentirc bump.
24
+ - **Steward backport** — port the 9.3.0 `pr-sonar.sh` + `workflow.sh` SonarCloud wiring back to the steward skills repo.
20
25
 
21
26
  Read the bootstrap spec at `docs/superpowers/specs/2026-04-30-bootstrap-design.md` for the full plan; it is the operative source of truth and is intentionally self-contained. The culture-side counterpart spec is at `../culture/docs/superpowers/specs/2026-04-30-agentirc-extraction-design.md` — not normally needed, but explains *why* if a decision looks arbitrary.
22
27
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentirc-cli
3
- Version: 9.3.0
3
+ Version: 9.4.0
4
4
  Summary: Agent-friendly IRCd: server core for AI agent meshes
5
5
  Project-URL: Homepage, https://github.com/OriNachum/agentirc
6
6
  Project-URL: Issues, https://github.com/OriNachum/agentirc/issues
@@ -40,6 +40,7 @@ Requires-Python: >=3.11
40
40
  Requires-Dist: opentelemetry-api>=1.22
41
41
  Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.22
42
42
  Requires-Dist: opentelemetry-sdk>=1.22
43
+ Requires-Dist: pyyaml>=6.0
43
44
  Provides-Extra: dev
44
45
  Requires-Dist: bandit; extra == 'dev'
45
46
  Requires-Dist: black; extra == 'dev'
@@ -54,6 +54,8 @@ import time
54
54
  from pathlib import Path
55
55
  from typing import Sequence
56
56
 
57
+ import yaml
58
+
57
59
  from agentirc import __version__
58
60
  from agentirc._internal.cli_shared.constants import (
59
61
  DEFAULT_CONFIG,
@@ -92,23 +94,116 @@ def _safe_log_name(name: str) -> str:
92
94
  return _safe_name(name)
93
95
 
94
96
 
95
- def _maybe_warn_unused_config(args: argparse.Namespace) -> None:
96
- """Emit a warning when ``--config`` is set to something that won't be loaded.
97
+ def _load_raw_yaml(cfg_path: str) -> dict:
98
+ """Read ``cfg_path`` as YAML and return the top-level mapping.
99
+
100
+ Missing files return ``{}``; malformed YAML raises ``yaml.YAMLError``
101
+ from the underlying loader. Empty files return ``{}``. A YAML
102
+ document whose root is a list/scalar (valid YAML but wrong shape)
103
+ raises ``yaml.YAMLError`` with a clear message rather than letting
104
+ a downstream ``AttributeError`` leak through.
105
+ """
106
+ p = Path(cfg_path).expanduser()
107
+ if not p.exists():
108
+ return {}
109
+ with p.open() as f:
110
+ raw = yaml.safe_load(f)
111
+ if raw is None:
112
+ return {}
113
+ if not isinstance(raw, dict):
114
+ raise yaml.YAMLError(
115
+ f"agentirc config {cfg_path!r}: root must be a mapping, "
116
+ f"got {type(raw).__name__}"
117
+ )
118
+ return raw
119
+
120
+
121
+ def _build_telemetry(yaml_telemetry: dict) -> "TelemetryConfig": # noqa: F821
122
+ """Build a TelemetryConfig from a YAML ``telemetry:`` block.
97
123
 
98
- ``agentirc`` does not yet wire ``~/.culture/server.yaml`` into the
99
- IRCd construction path ``ServerConfig`` is built purely from CLI
100
- flags. Silently ignoring a non-default ``--config`` was flagged as
101
- confusing in PR #4 review (Qodo + Copilot). Until YAML loading
102
- lands, warn explicitly so users notice.
124
+ Drops keys not declared on the dataclass — culture extending
125
+ TelemetryConfig in the future shouldn't crash agentirc on read.
126
+ """
127
+ from agentirc.config import TelemetryConfig
128
+
129
+ if not yaml_telemetry:
130
+ return TelemetryConfig()
131
+ known = {f.name for f in TelemetryConfig.__dataclass_fields__.values()}
132
+ tcfg = {k: v for k, v in yaml_telemetry.items() if k in known}
133
+ return TelemetryConfig(**tcfg)
134
+
135
+
136
+ def _resolve_links(cli_links, yaml_links: list) -> list:
137
+ """Pick CLI links if any, else build from YAML, else empty.
138
+
139
+ CLI links replace YAML wholesale — there is no merge.
140
+ """
141
+ from agentirc.config import LinkConfig
142
+
143
+ if cli_links:
144
+ return list(cli_links)
145
+ if yaml_links:
146
+ return [LinkConfig(**entry) for entry in yaml_links]
147
+ return []
148
+
149
+
150
+ def _resolve_config(args: argparse.Namespace) -> "ServerConfig": # noqa: F821 (forward ref)
151
+ """Build a ServerConfig from ``--config`` YAML, overlaid with CLI flags.
152
+
153
+ Precedence (highest first): explicit CLI flag (``not None``) >
154
+ matching YAML key > built-in default. Loads raw YAML so we can
155
+ distinguish "key absent from YAML" from "key present but matching
156
+ the dataclass default" — needed to let downstream
157
+ ``_resolve_server_name`` handle the "no name anywhere" case via
158
+ the default-server file rather than the ``ServerConfig`` dataclass
159
+ default ``"culture"``.
160
+
161
+ Mutates ``args`` in place so ``args.host`` / ``args.port`` /
162
+ ``args.name`` / ``args.webhook_port`` / ``args.data_dir`` /
163
+ ``args.link`` reflect the resolved values for the daemonize / log
164
+ / status code that reads them directly. ``args.name`` is left
165
+ ``None`` if neither CLI nor YAML supplied a name; the handler
166
+ then calls ``_resolve_server_name`` to pick up the default-server
167
+ file or the ``agentirc`` fallback.
103
168
  """
104
- cfg = getattr(args, "config", None)
105
- if cfg and cfg != DEFAULT_CONFIG:
106
- print(
107
- f"agentirc: warning: --config {cfg!r} was supplied, but YAML config "
108
- "loading is not yet wired (PR-B4). Server settings will come from "
109
- "CLI flags (--host/--port/--link/--data-dir) only.",
110
- file=sys.stderr,
169
+ from agentirc.config import ServerConfig
170
+
171
+ raw = _load_raw_yaml(getattr(args, "config", None) or DEFAULT_CONFIG)
172
+ yaml_server = raw.get("server") or {}
173
+
174
+ def _pick(cli_value, yaml_value, default):
175
+ if cli_value is not None:
176
+ return cli_value
177
+ return yaml_value if yaml_value is not None else default
178
+
179
+ name = _pick(args.name, yaml_server.get("name"), None)
180
+ host = _pick(args.host, yaml_server.get("host"), "0.0.0.0")
181
+ port = _pick(args.port, yaml_server.get("port"), 6667)
182
+ webhook_port = _pick(args.webhook_port, raw.get("webhook_port"), 7680)
183
+ data_dir = os.path.expanduser(
184
+ _pick(
185
+ args.data_dir, raw.get("data_dir"), os.path.expanduser("~/.culture/data")
111
186
  )
187
+ )
188
+
189
+ cfg = ServerConfig(
190
+ name=name or "agentirc",
191
+ host=host,
192
+ port=port,
193
+ webhook_port=webhook_port,
194
+ data_dir=data_dir,
195
+ links=_resolve_links(getattr(args, "link", None), raw.get("links") or []),
196
+ system_bots=raw.get("system_bots") or {},
197
+ telemetry=_build_telemetry(raw.get("telemetry") or {}),
198
+ )
199
+
200
+ args.name = name # may be None — handler resolves via default-server file
201
+ args.host = cfg.host
202
+ args.port = cfg.port
203
+ args.webhook_port = cfg.webhook_port
204
+ args.data_dir = cfg.data_dir
205
+ args.link = cfg.links
206
+ return cfg
112
207
 
113
208
 
114
209
  # ---------------------------------------------------------------------------
@@ -117,21 +212,34 @@ def _maybe_warn_unused_config(args: argparse.Namespace) -> None:
117
212
 
118
213
 
119
214
  def _add_start_flags(parser: argparse.ArgumentParser) -> None:
120
- """Attach the lifecycle flag set used by ``serve``/``start``/``restart``."""
215
+ """Attach the lifecycle flag set used by ``serve``/``start``/``restart``.
216
+
217
+ All flags default to ``None`` (the *sentinel* default) rather than
218
+ their concrete values. ``_resolve_config`` distinguishes "user
219
+ supplied this flag" from "argparse filled in the default" and
220
+ overlays CLI values on top of YAML; sentinel defaults are required
221
+ for that distinction. The user-visible defaults — ``0.0.0.0``,
222
+ ``6667``, ``7680``, ``~/.culture/data`` — are documented in the
223
+ help strings and live on the ``ServerConfig`` dataclass.
224
+ """
121
225
  parser.add_argument("--name", default=None, help=_SERVER_NAME_HELP)
122
- parser.add_argument("--host", default="0.0.0.0", help="Listen address")
123
- parser.add_argument("--port", type=int, default=6667, help="Listen port")
226
+ parser.add_argument(
227
+ "--host", default=None, help="Listen address (default: 0.0.0.0)"
228
+ )
229
+ parser.add_argument(
230
+ "--port", type=int, default=None, help="Listen port (default: 6667)"
231
+ )
124
232
  parser.add_argument(
125
233
  "--link",
126
234
  type=parse_link,
127
235
  action="append",
128
- default=[],
236
+ default=None,
129
237
  help="Link to peer: name:host:port:password[:trust]",
130
238
  )
131
239
  parser.add_argument(
132
240
  "--webhook-port",
133
241
  type=int,
134
- default=7680,
242
+ default=None,
135
243
  help=(
136
244
  "HTTP port for bot webhooks (default: 7680; "
137
245
  "inert in agentirc until a bot harness is wired in)"
@@ -139,7 +247,7 @@ def _add_start_flags(parser: argparse.ArgumentParser) -> None:
139
247
  )
140
248
  parser.add_argument(
141
249
  "--data-dir",
142
- default=os.path.expanduser("~/.culture/data"),
250
+ default=None,
143
251
  help="Data directory for persistent storage (default: ~/.culture/data)",
144
252
  )
145
253
  parser.add_argument("--config", default=DEFAULT_CONFIG, help=_CONFIG_HELP)
@@ -309,29 +417,21 @@ def _force_kill(pid: int, name: str) -> None:
309
417
  # ---------------------------------------------------------------------------
310
418
 
311
419
 
312
- async def _run_server(
313
- name: str,
314
- host: str,
315
- port: int,
316
- links: list | None = None,
317
- webhook_port: int = 7680,
318
- data_dir: str = "",
319
- ) -> None:
320
- """Run the IRC server (called in the daemon child process)."""
321
- from agentirc.config import ServerConfig
420
+ async def _run_server(config: "ServerConfig") -> None: # noqa: F821 (forward ref)
421
+ """Run the IRC server (called in the daemon child process).
422
+
423
+ Takes a fully-merged ``ServerConfig`` so YAML-supplied telemetry,
424
+ system_bots, and any future dataclass fields reach the IRCd.
425
+ Caller (``_run_foreground`` / ``_daemonize_server``) is responsible
426
+ for resolving CLI/YAML precedence via ``_resolve_config``.
427
+ """
322
428
  from agentirc.ircd import IRCd
323
429
 
324
- config = ServerConfig(
325
- name=name,
326
- host=host,
327
- port=port,
328
- webhook_port=webhook_port,
329
- links=links or [],
330
- data_dir=data_dir,
331
- )
332
430
  ircd = IRCd(config)
333
431
  await ircd.start()
334
- logger.info("Server '%s' listening on %s:%d", name, host, port)
432
+ logger.info(
433
+ "Server '%s' listening on %s:%d", config.name, config.host, config.port
434
+ )
335
435
 
336
436
  for lc in config.links:
337
437
  try:
@@ -356,41 +456,41 @@ async def _run_server(
356
456
  signal.signal(sig, lambda *_: stop_event.set())
357
457
 
358
458
  await stop_event.wait()
359
- logger.info("Server '%s' shutting down", name)
459
+ logger.info("Server '%s' shutting down", config.name)
360
460
  await ircd.stop()
361
461
 
362
462
 
363
- def _run_foreground(args: argparse.Namespace, pid_name: str, links: list) -> None:
463
+ def _run_foreground(pid_name: str, cfg: "ServerConfig") -> None: # noqa: F821
364
464
  """Run the server in the foreground (blocking).
365
465
 
366
466
  A PID file is written when *pid_name* is non-empty (``start
367
467
  --foreground``). The agentirc-only ``serve`` verb passes ``""`` to
368
468
  skip PID writes — useful for systemd ``Type=simple`` and containers
369
- that own process supervision.
469
+ that own process supervision. All runtime values come from *cfg*;
470
+ ``args`` is no longer needed since the verb-handler resolves the
471
+ YAML+CLI merge before calling here.
370
472
  """
371
473
  if pid_name:
372
474
  write_pid(pid_name, os.getpid())
373
- if args.port:
475
+ if cfg.port:
374
476
  # Mirror daemonize: write the port file so `agentirc status`
375
477
  # can report `(PID N, port P)` for foreground-managed runs.
376
- write_port(pid_name, args.port)
478
+ write_port(pid_name, cfg.port)
377
479
  os.makedirs(LOG_DIR, exist_ok=True)
378
- print(f"Server '{args.name}' starting in foreground (PID {os.getpid()})")
379
- print(f" Listening on {args.host}:{args.port}")
380
- print(f" Webhook port: {args.webhook_port}")
480
+ print(f"Server '{cfg.name}' starting in foreground (PID {os.getpid()})")
481
+ print(f" Listening on {cfg.host}:{cfg.port}")
482
+ print(f" Webhook port: {cfg.webhook_port}")
381
483
  if pid_name:
382
- _maybe_set_default_server(args.name)
484
+ _maybe_set_default_server(cfg.name)
383
485
  try:
384
- asyncio.run(
385
- _run_server(args.name, args.host, args.port, links, args.webhook_port, args.data_dir)
386
- )
486
+ asyncio.run(_run_server(cfg))
387
487
  finally:
388
488
  if pid_name:
389
489
  remove_pid(pid_name)
390
490
  remove_port(pid_name)
391
491
 
392
492
 
393
- def _daemonize_server(args: argparse.Namespace, pid_name: str, links: list) -> None:
493
+ def _daemonize_server(args: argparse.Namespace, pid_name: str, cfg: "ServerConfig") -> None: # noqa: F821
394
494
  """Fork and set up the daemon child process for the server."""
395
495
  if sys.platform == "win32":
396
496
  print("Daemon mode not supported on Windows. Use --foreground.", file=sys.stderr)
@@ -404,7 +504,7 @@ def _daemonize_server(args: argparse.Namespace, pid_name: str, links: list) -> N
404
504
  os.setsid()
405
505
 
406
506
  os.makedirs(LOG_DIR, exist_ok=True)
407
- log_path = os.path.join(LOG_DIR, f"server-{_safe_log_name(args.name)}.log")
507
+ log_path = os.path.join(LOG_DIR, f"server-{_safe_log_name(cfg.name)}.log")
408
508
  log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
409
509
  os.dup2(log_fd, 1)
410
510
  os.dup2(log_fd, 2)
@@ -426,8 +526,8 @@ def _daemonize_server(args: argparse.Namespace, pid_name: str, links: list) -> N
426
526
  )
427
527
 
428
528
  write_pid(pid_name, os.getpid())
429
- if args.port:
430
- write_port(pid_name, args.port)
529
+ if cfg.port:
530
+ write_port(pid_name, cfg.port)
431
531
 
432
532
  # PR #4 review (Qodo): the previous version called os._exit(0)
433
533
  # unconditionally in the finally block, masking crashes inside
@@ -436,16 +536,14 @@ def _daemonize_server(args: argparse.Namespace, pid_name: str, links: list) -> N
436
536
  # and propagate it.
437
537
  rc = 0
438
538
  try:
439
- asyncio.run(
440
- _run_server(args.name, args.host, args.port, links, args.webhook_port, args.data_dir)
441
- )
539
+ asyncio.run(_run_server(cfg))
442
540
  except Exception:
443
541
  # Catch ordinary failures so the daemon child can record them and
444
542
  # exit non-zero. SystemExit / KeyboardInterrupt / GeneratorExit
445
543
  # deliberately propagate (the asyncio signal handlers translate
446
544
  # SIGINT/SIGTERM into a clean stop_event.set, so we never reach
447
545
  # this except via an actual fault).
448
- logger.exception("Daemon for server '%s' crashed", args.name)
546
+ logger.exception("Daemon for server '%s' crashed", cfg.name)
449
547
  rc = 1
450
548
  finally:
451
549
  remove_pid(pid_name)
@@ -460,25 +558,26 @@ def _daemonize_server(args: argparse.Namespace, pid_name: str, links: list) -> N
460
558
 
461
559
  def _server_serve(args: argparse.Namespace) -> None:
462
560
  """``agentirc serve`` — run the IRCd in the foreground without writing a PID file."""
463
- args.name = _resolve_server_name(args)
464
- _maybe_warn_unused_config(args)
465
- links = list(getattr(args, "link", []) or [])
466
- _run_foreground(args, pid_name="", links=links)
561
+ cfg = _resolve_config(args)
562
+ if args.name is None:
563
+ args.name = _resolve_server_name(args)
564
+ cfg.name = args.name
565
+ _run_foreground(pid_name="", cfg=cfg)
467
566
 
468
567
 
469
568
  def _server_start(args: argparse.Namespace) -> None:
470
569
  """``agentirc start`` — daemonize (or run foreground if ``--foreground``)."""
471
- args.name = _resolve_server_name(args)
472
- _maybe_warn_unused_config(args)
570
+ cfg = _resolve_config(args)
571
+ if args.name is None:
572
+ args.name = _resolve_server_name(args)
573
+ cfg.name = args.name
473
574
  pid_name = f"server-{args.name}"
474
575
  _check_already_running(pid_name, args.name)
475
576
 
476
- links = list(getattr(args, "link", []) or [])
477
-
478
577
  if getattr(args, "foreground", False):
479
- _run_foreground(args, pid_name, links)
578
+ _run_foreground(pid_name, cfg)
480
579
  else:
481
- _daemonize_server(args, pid_name, links)
580
+ _daemonize_server(args, pid_name, cfg)
482
581
 
483
582
 
484
583
  def _server_stop(args: argparse.Namespace) -> int:
@@ -522,8 +621,16 @@ def _server_stop(args: argparse.Namespace) -> int:
522
621
 
523
622
 
524
623
  def _server_restart(args: argparse.Namespace) -> int:
525
- """``agentirc restart`` — stop (best-effort) then start with the same args."""
526
- args.name = _resolve_server_name(args)
624
+ """``agentirc restart`` — stop (best-effort) then start with the same args.
625
+
626
+ Resolves the YAML+CLI merge once up-front so ``server.name`` from
627
+ ``--config`` is honoured for both the stop half and the subsequent
628
+ ``_server_start`` call. Without the pre-resolution, the default-
629
+ server file would shadow ``server.name``.
630
+ """
631
+ _resolve_config(args)
632
+ if args.name is None:
633
+ args.name = _resolve_server_name(args)
527
634
  pid_name = f"server-{args.name}"
528
635
  pid = read_pid(pid_name)
529
636
  if pid and is_process_alive(pid):
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ @dataclass
11
+ class LinkConfig:
12
+ """Configuration for a server-to-server link."""
13
+
14
+ name: str
15
+ host: str
16
+ port: int
17
+ password: str
18
+ trust: str = "full" # "full" or "restricted"
19
+
20
+
21
+ @dataclass
22
+ class TelemetryConfig:
23
+ """OpenTelemetry settings. Mirrors server.yaml `telemetry:` block."""
24
+
25
+ enabled: bool = False
26
+ service_name: str = "culture.agentirc"
27
+ otlp_endpoint: str = "http://localhost:4317"
28
+ otlp_protocol: str = "grpc" # grpc | http/protobuf (only grpc supported initially)
29
+ otlp_timeout_ms: int = 5000
30
+ otlp_compression: str = "gzip" # gzip | none
31
+ traces_enabled: bool = True
32
+ traces_sampler: str = "parentbased_always_on"
33
+ metrics_enabled: bool = True
34
+ metrics_export_interval_ms: int = 10000
35
+ # Audit JSONL sink (Plan 4). Independent of `enabled` — audit fires
36
+ # even when telemetry is off so admins always have the trail.
37
+ audit_enabled: bool = True
38
+ audit_dir: str = "~/.culture/audit"
39
+ audit_max_file_bytes: int = 256 * 1024 * 1024 # 256 MiB
40
+ audit_rotate_utc_midnight: bool = True
41
+ audit_queue_depth: int = 10000
42
+
43
+
44
+ @dataclass
45
+ class ServerConfig:
46
+ """Configuration for a culture server instance."""
47
+
48
+ name: str = "culture"
49
+ host: str = "0.0.0.0"
50
+ port: int = 6667
51
+ webhook_port: int = 7680
52
+ data_dir: str = ""
53
+ links: list[LinkConfig] = field(default_factory=list)
54
+ system_bots: dict = field(default_factory=dict)
55
+ telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
56
+
57
+ @classmethod
58
+ def from_yaml(cls, path: str | Path) -> "ServerConfig":
59
+ """Load a ServerConfig from a YAML file.
60
+
61
+ Recognises top-level ``server`` (host/port/name), ``telemetry``,
62
+ ``links``, ``webhook_port``, ``data_dir``, and ``system_bots``.
63
+ Unknown top-level keys (``supervisor``, ``agents``, ``buffer_size``,
64
+ ``poll_interval``, ``sleep_start``, ``sleep_end``) are silently
65
+ ignored — those belong to culture's broader process supervisor,
66
+ and agentirc must coexist with culture using the same
67
+ ``~/.culture/server.yaml`` file. Unknown keys *inside* the
68
+ ``server:`` block are also tolerated for the same reason
69
+ (culture's ``ServerConnConfig`` carries ``archived``,
70
+ ``archived_at``, ``archived_reason`` that agentirc has no use
71
+ for).
72
+
73
+ A missing path returns the dataclass defaults rather than
74
+ raising — callers (CLI handlers) treat the file as optional.
75
+ Malformed YAML raises ``yaml.YAMLError`` from the underlying
76
+ loader; we deliberately do not catch it so users see the parse
77
+ error.
78
+ """
79
+ p = Path(path).expanduser()
80
+ if not p.exists():
81
+ return cls()
82
+ raw = _load_root_mapping(p)
83
+ return cls(**_yaml_kwargs(raw))
84
+
85
+
86
+ def _load_root_mapping(p: Path) -> dict[str, Any]:
87
+ """Load YAML at *p* and require the root to be a mapping."""
88
+ with p.open() as f:
89
+ loaded = yaml.safe_load(f)
90
+ if loaded is None:
91
+ return {}
92
+ if not isinstance(loaded, dict):
93
+ raise yaml.YAMLError(
94
+ f"agentirc config {str(p)!r}: root must be a mapping, "
95
+ f"got {type(loaded).__name__}"
96
+ )
97
+ return loaded
98
+
99
+
100
+ def _yaml_kwargs(raw: dict[str, Any]) -> dict[str, Any]:
101
+ """Project a raw YAML mapping onto ServerConfig constructor kwargs."""
102
+ server_section = raw.get("server") or {}
103
+ kwargs: dict[str, Any] = {}
104
+ for key in ("name", "host", "port"):
105
+ if key in server_section:
106
+ kwargs[key] = server_section[key]
107
+ for key in ("webhook_port", "data_dir"):
108
+ if key in raw:
109
+ kwargs[key] = raw[key]
110
+ links_section = raw.get("links") or []
111
+ if links_section:
112
+ kwargs["links"] = [LinkConfig(**entry) for entry in links_section]
113
+ telemetry_section = raw.get("telemetry") or {}
114
+ if telemetry_section:
115
+ kwargs["telemetry"] = _build_telemetry(telemetry_section)
116
+ system_bots = raw.get("system_bots") or {}
117
+ if system_bots:
118
+ kwargs["system_bots"] = system_bots
119
+ return kwargs
120
+
121
+
122
+ def _build_telemetry(yaml_telemetry: dict) -> TelemetryConfig:
123
+ """Build a TelemetryConfig, dropping keys not on the dataclass."""
124
+ known = {f.name for f in TelemetryConfig.__dataclass_fields__.values()}
125
+ tcfg = {k: v for k, v in yaml_telemetry.items() if k in known}
126
+ return TelemetryConfig(**tcfg)