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
272 lines
13 KiB
Python
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)}")
|