Files
chemenu/tools/chemenu/commands/config_cmd.py
T
torbenandClaude Code db8f90c229
CI / verify (push) Failing after 7m41s
CI / pwsh (push) Successful in 2m32s
Release / release (push) Successful in 37s
Release / wiki-mirror (push) Skipped
settings: the four per-feature files .wikitool-remotes/-telemetry/-upload/-tasks.json move into the three tiers and disappear; new command config migrate; 10.0.0-beta.1 (#208)
Files changed:
- .gitignore
- AGENTS.md
- CHANGES.md
- INSTALL.md
- VERSION
- docs/explanation/knowledge-and-commitment.md
- docs/explanation/telemetry-and-evaluation.md
- docs/explanation/why-gates-are-code.md
- docs/how-to/run-the-weekly-review.md
- docs/how-to/set-up-the-mcp-server.md
- docs/how-to/trace-and-score-sessions.md
- docs/how-to/troubleshoot.md
- docs/reference/commands/README.md
- docs/reference/commands/configuration.md
- docs/reference/commands/finding-and-checking.md
- docs/reference/commands/git.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/pages.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/configuration.md
- docs/reference/exit-codes.md
- instructions/dev/doc-pull-through.md
- instructions/dev/tracker-testing.md
- instructions/gates.md
- instructions/ingest-queue.md
- instructions/mcp-read-server.md
- instructions/migrate-corpus.md
- instructions/migrations/10.0.0-settings-tiers.md
- instructions/setup-instance.md
- raw/CONTRACT.md
- reports/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/bugreport.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/_util.py
- tools/chemenu/commands/config_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/export_cmd.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/commands/upload_cmd.py
- tools/chemenu/config.py
- tools/chemenu/config_shapes.py
- tools/chemenu/legacy_settings.py
- tools/chemenu/mcp/__main__.py
- tools/chemenu/mcp/server.py
- tools/chemenu/reference_doc.py
- tools/chemenu/review.py
- tools/chemenu/settings.py
- tools/chemenu/tasks/__init__.py
- tools/chemenu/tasks/caldav.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/telemetry/policy.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/record_sp_fixtures.py
- tools/chemenu/tests/test_bugreport.py
- tools/chemenu/tests/test_caldav.py
- tools/chemenu/tests/test_config_shapes.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_git_publish.py
- tools/chemenu/tests/test_legacy_settings.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_settings.py
- tools/chemenu/tests/test_sp_recorded.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_task_cmd.py
- tools/chemenu/tests/test_tasks_config.py
- tools/chemenu/tests/test_telemetry_policy.py
- tools/chemenu/tests/test_tracker_live.py
- tools/chemenu/tests/test_upload.py
- tools/chemenu/tests/tracker_live.py
- tools/chemenu/upload.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-11 06:54:35 +02:00

272 lines
13 KiB
Python

"""`wikitool config set` and `config migrate` - write the tiered settings, checked.
The file a value lands in follows from its field's tier (`chemenu.settings`),
never from the caller: a plain `instance` value goes into `wikitool.json`, a
plain `checkout` or private value into `.wikitool-local.json`, and a reference
into `wikitool.json`, where every clone of the instance reads it. Both files are
checked as they would stand after the write, and nothing is written when that
check fails.
Two things it refuses on purpose. A secret's value: whatever runs this command
would see it, and an agent is what runs it most of the time - the value goes
into `WIKITOOL_SECRET_<NAME>`, the secrets file or 1Password, by the user. And a
field that names a program to run or affects a gate: like the Publish-Remote
Gate's allowlist, that one is changed by the user's own edit, never by an agent.
`config migrate` is the one-time move out of the four per-feature files of
earlier versions (`chemenu.legacy_settings`). It prints which key went where,
never a value.
"""
from __future__ import annotations
import json
from typing import Any, Optional
import typer
from chemenu import cli_contract, config, legacy_settings, settings
from chemenu.config_shapes import INSTANCE, SECRET, Field
from chemenu.commands._util import fail, needs_clearance, success
from chemenu.errors import ValidationError
app = typer.Typer(help="Write the tiered settings in wikitool.json and .wikitool-local.json.")
def _plain_value(spec: Field, raw: str) -> Any:
"""`raw` as the JSON value `spec` takes: a string as given, anything else
parsed as JSON (`true`, `14`, `["a"]`)."""
if spec.json_type in (None, "string"):
return raw
try:
return json.loads(raw)
except ValueError:
raise ValidationError(f"{spec.name} takes a JSON {spec.json_type}, and the value given is not JSON") from None
@app.command("set")
@cli_contract.record(cli_contract.CommandRecord(
path="config set",
summary="Set one value of the tiered settings, in the file its tier names.",
synopsis=(
cli_contract.Variant(usage="config set <section>.<field> <value>"),
cli_contract.Variant(usage="config set <section>.<field> --ref <name|op://vault/item/field>"),
),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Each file is replaced in one rename; both are checked before either is written",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Writes a plain value of a field of tier `instance` into `wikitool.json`, and one of tier "
"`checkout` - a private field's included - into `.wikitool-local.json`.",
"With `--ref`, writes a reference instead of a value into `wikitool.json`: `<name>` "
"resolves through `WIKITOOL_SECRET_<NAME>`, then the secrets file in the user's "
"configuration directory; `op://<vault>/<item>/<field>` resolves through the 1Password "
"CLI. Only a secret or private field takes one.",
"Removes the same key from the other file, so a value never has two places, and says so.",
"Creates `wikitool.json` with a fresh `instance_id` the first time it writes anything; "
"the secrets file is found by that id.",
"Checks both files as they would stand after the write against every registered section, "
"and writes nothing when that fails.",
"Prints the key and the file it wrote, never the value. Resolves no reference.",
),
failures=(
cli_contract.Failure(
cause="The key names no registered section or field, or is not `<section>.<field>`",
reaction="Use a key from the list the message prints; `docs/reference/configuration.md` "
"describes the sections",
),
cli_contract.Failure(
cause="A plain value given for a field of tier `secret`",
reaction="Do not retry with the value. Ask the user to put it into "
"`WIKITOOL_SECRET_<NAME>`, the secrets file or 1Password, then set the field with "
"`--ref`",
),
cli_contract.Failure(
cause="`--ref` given for a field that takes a plain value, or a malformed reference",
reaction="Give the value itself, or fix the reference to `<name>` (lower case, digits, "
"`_`) or `op://<vault>/<item>/<field>`",
),
cli_contract.Failure(
cause="The value is not of the field's type, or the files would not pass their check "
"afterwards",
reaction="Fix the value or the problem the message names in the file it names, then "
"re-run once",
),
cli_contract.Failure(
cause="The field names a program to run or affects a gate",
reaction="Show the user the output verbatim and stop. Only the user sets this field, "
"by editing `.wikitool-local.json` themselves",
code=42,
),
),
examples=(
"tools/wikitool config set tasks.stalled_waiting_days 14",
"tools/wikitool config set caldav.app_password --ref caldav_app_password",
"tools/wikitool config set caldav.username --ref op://Chemenu/caldav/username",
),
never=(
"Never pass a secret's value on the command line - not to this command and not to any other.",
"Never read the secrets file or run `op read` yourself to see a value.",
),
see_also=(
"`wikitool doctor` - whether both files pass and every reference resolves",
"`docs/reference/configuration.md` - the files, the tiers and the references",
"`docs/how-to/use-1password-for-settings.md` - setting 1Password up as the source",
),
))
def set_command(
key: str = typer.Argument(..., help="<section>.<field>"),
value: Optional[str] = typer.Argument(None, help="The plain value; a string as given, anything else as JSON."),
ref: Optional[str] = typer.Option(None, "--ref", help="A reference instead of a value: <name> or op://<vault>/<item>/<field>."),
):
"""Set one value of the tiered settings, in the file its tier names."""
if (value is None) == (ref is None):
fail("Give either a value or --ref, not both and not neither")
try:
shape, spec = settings.find_field(key)
except ValidationError as exc:
fail(str(exc))
if spec.user_only:
needs_clearance(
f"{key} names a program to run or affects a gate, so only the user sets it - by "
f"editing {config.LOCAL_SETTINGS_FILENAME} at the root of this checkout themselves. "
"`wikitool doctor` checks the file afterwards."
)
if ref is not None:
if not spec.takes_ref:
fail(f"{key} takes a plain value, not a reference - only a secret or a private field takes one")
stored: Any = {settings.REF_KEY: ref}
try:
settings.parse_ref(stored)
except ValidationError as exc:
fail(f"{key}: {exc}")
in_instance = True
else:
if spec.tier == SECRET:
fail(f"{key} is tier {SECRET!r}: its value never passes through `config set`, where "
"whoever runs the command would see it. Put it into "
f"{config.ENV_SECRET_PREFIX}<NAME>, the secrets file or 1Password, then set "
f"{key} with --ref.")
try:
stored = _plain_value(spec, value)
except ValidationError as exc:
fail(f"{key}: {exc}")
in_instance = spec.tier == INSTANCE
instance_file, local_file = settings.instance_path(), settings.local_path()
try:
instance_data = settings.read_file(instance_file)
local_data = settings.read_file(local_file)
except ValidationError as exc:
fail(str(exc))
instance_data = dict(instance_data or {})
local_data = dict(local_data or {})
instance_data.setdefault("schema", settings.SCHEMA)
created_id = settings.INSTANCE_ID_KEY not in instance_data
instance_data.setdefault(settings.INSTANCE_ID_KEY, settings.new_instance_id())
target, other = (instance_data, local_data) if in_instance else (local_data, instance_data)
target.setdefault("schema", settings.SCHEMA)
target_section = dict(target.get(shape.key) or {})
target_section[spec.name] = stored
target[shape.key] = target_section
removed = False
other_section = other.get(shape.key)
if isinstance(other_section, dict) and spec.name in other_section:
other_section = {k: v for k, v in other_section.items() if k != spec.name}
if other_section:
other[shape.key] = other_section
else:
del other[shape.key]
removed = True
write_local = not in_instance or removed or local_file.exists()
try:
settings.check_files(instance_data, local_data if (write_local or local_data) else None,
complete=False)
except ValidationError as exc:
fail(f"Nothing written - the settings would not pass their check:\n{exc}")
settings.write_file(instance_file, instance_data)
if write_local:
settings.write_file(local_file, local_data)
where = config.INSTANCE_SETTINGS_FILENAME if in_instance else config.LOCAL_SETTINGS_FILENAME
other_name = config.LOCAL_SETTINGS_FILENAME if in_instance else config.INSTANCE_SETTINGS_FILENAME
what = "reference" if ref is not None else "value"
message = f"{key}: {what} set in {where}"
if removed:
message += f"; removed from {other_name}"
if created_id:
message += f" ({config.INSTANCE_SETTINGS_FILENAME} created with a new {settings.INSTANCE_ID_KEY})"
success(message)
@app.command("migrate")
@cli_contract.record(cli_contract.CommandRecord(
path="config migrate",
summary="Move the settings out of the per-feature files of earlier versions into the tiered settings.",
synopsis=(cli_contract.Variant(usage="config migrate"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Everything is computed and checked in memory first; the secrets file, "
"`wikitool.json` and `.wikitool-local.json` are each replaced in one rename, read back and "
"checked, and only then are the old files removed. Any failure before that puts every "
"target back to the bytes it had and leaves the old files as they were",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Moves the four per-feature settings files of earlier versions - telemetry, remotes, "
"upload and the task tracker, each named in the migration document - into the sections "
"`telemetry`, `remotes`, `upload`, and `tasks`, `superproductivity` and `caldav`: each value into the file its field's tier "
"names, a secret's value into the secrets file with only a reference in `wikitool.json`.",
"Every setting keeps its effective value, defaults included: the old content is read "
"with the same checks the new sections use, and a field the old file did not set is not "
"written.",
"Run once per checkout, since the old files and `.wikitool-local.json` belong to one "
"checkout. `.wikitool-tools.json` stays as it is.",
"Prints each key and the file it went to, plus anything the old file held that was not "
"read before and is not carried over - never a value.",
"A second run, or a run with no old file, prints that there is nothing to move and "
"changes no file.",
"A key the target files already hold with a different value stops the move before "
"anything is written: decide which one stands, remove the other, run again.",
),
failures=(
cli_contract.Failure(
cause="An old file does not pass the check the new section applies, a target key "
"holds a different value, or the files would not pass their check afterwards",
reaction="Nothing was changed. Fix the problem the message names, then re-run once",
),
),
examples=("tools/wikitool config migrate",),
never=(
"Never read the secrets file or an old file yourself to see a value.",
"Never delete an old file by hand to make a leftover message go away - the move is what "
"carries its settings over.",
),
see_also=(
"`wikitool doctor` - reports every leftover old file as a `FAIL`",
"`docs/reference/configuration.md` - the sections the files became",
"`instructions/migrations/10.0.0-settings-tiers.md` - the migration this command belongs to",
),
))
def migrate_command():
"""Move the per-feature settings files of earlier versions into the tiered settings."""
try:
result = legacy_settings.migrate(config.ROOT)
except ValidationError as exc:
fail(str(exc))
if not result.removed:
success("Nothing to migrate - no settings file of an earlier version is left")
return
for line in result.moved:
typer.echo(f" {line}")
for note in result.notes:
typer.echo(f" note: {note}")
success(f"Moved {len(result.moved)} setting(s); removed {', '.join(result.removed)}")