zikaron 0.1.0__py3-none-any.whl
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.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- zikaron-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
"""Putting the shipped artefacts on disk, including the one operation that touches a file the user
|
|
2
|
+
owns.
|
|
3
|
+
|
|
4
|
+
`architecture.md` §"The install contract" is normative. Three rules run through everything here:
|
|
5
|
+
|
|
6
|
+
- **Everything is checked before anything is written.** A `plan_*` function parses and guards the
|
|
7
|
+
file it would touch and produces the document it *would* write, touching nothing; `commit_merge`
|
|
8
|
+
writes it. An earlier arrangement wrote the shipped files first and discovered a malformed config
|
|
9
|
+
afterwards, which left a project half-installed — the exact outcome the contract's own ordering
|
|
10
|
+
exists to prevent.
|
|
11
|
+
- **Refuse rather than overwrite; keep rather than clobber.** A shipped file whose bytes are already
|
|
12
|
+
what this install ships is kept and reported; one that differs is backed up and refreshed, which
|
|
13
|
+
is what makes a re-run after an *upgrade* correct rather than merely safe. A Zikaron entry in a
|
|
14
|
+
file the user owns that differs from what this install would write is a *refusal*, because that
|
|
15
|
+
means another install owns it.
|
|
16
|
+
- **A merge is backed up first, and the first backup wins.** `<config>.bak` is written only when
|
|
17
|
+
nothing is there — not even a dangling symlink — because overwriting it on every run would replace
|
|
18
|
+
the pristine original with the copy the first install had already modified.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import errno
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shlex
|
|
25
|
+
import shutil
|
|
26
|
+
import tempfile
|
|
27
|
+
from collections.abc import Iterator
|
|
28
|
+
from contextlib import contextmanager
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from fnmatch import fnmatch
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Final, NamedTuple
|
|
33
|
+
|
|
34
|
+
from zikaron.install.assets import SKILL_NAME
|
|
35
|
+
from zikaron.install.entries import (
|
|
36
|
+
CONSOLIDATOR_AGENT_NAME,
|
|
37
|
+
MCP_SERVER_NAME,
|
|
38
|
+
TOOL_SELECTOR,
|
|
39
|
+
Commands,
|
|
40
|
+
HookFormat,
|
|
41
|
+
hooks_array,
|
|
42
|
+
hooks_object,
|
|
43
|
+
mcp_servers_value,
|
|
44
|
+
)
|
|
45
|
+
from zikaron.install.harness import InstallError
|
|
46
|
+
|
|
47
|
+
_JSON_INDENT = 2
|
|
48
|
+
|
|
49
|
+
#: How a skill is named in `resources`. Skills are progressively loaded, unlike `file://` entries.
|
|
50
|
+
_SKILL_SCHEME: Final = "skill://"
|
|
51
|
+
|
|
52
|
+
#: Said in two places — after a merge and beside a printed fragment — because it is the one way an
|
|
53
|
+
#: array-format install differs from an object-format one in what it can promise.
|
|
54
|
+
ARRAY_FORMAT_OUTPUT_CAP_NOTE: Final = (
|
|
55
|
+
"The array hook format documents no `max_output_size` field, so those entries inherit the "
|
|
56
|
+
"harness default of 10240 bytes rather than the 65536 an object-format entry states."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Targets:
|
|
62
|
+
"""Where **kiro's** shipped artefacts go for one project directory.
|
|
63
|
+
|
|
64
|
+
Kiro-specific, and named so rather than generalised: Claude Code's four artefacts live at
|
|
65
|
+
different paths under a different dotdir and are computed by `ClaudeCodeTarget`. A single
|
|
66
|
+
`Targets` covering both would be a union type whose fields are half-null on either harness,
|
|
67
|
+
which is the shape `coding-standards.md` §2 asks us not to build.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
project: Path
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def agents_dir(self) -> Path:
|
|
74
|
+
return self.project / ".kiro" / "agents"
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def consolidator_config(self) -> Path:
|
|
78
|
+
return self.agents_dir / f"{CONSOLIDATOR_AGENT_NAME}.json"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def skill_file(self) -> Path:
|
|
82
|
+
return self.project / ".kiro" / "skills" / SKILL_NAME / "SKILL.md"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ShippedFile(NamedTuple):
|
|
86
|
+
"""One artefact this install owns outright: its path and the exact bytes that belong there.
|
|
87
|
+
|
|
88
|
+
The unit both harnesses' writers agree on. Kiro ships two, Claude Code ships two — and the
|
|
89
|
+
*merged* files are not among them on either harness, because a merge target belongs to the user
|
|
90
|
+
and a shipped file does not.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
path: Path
|
|
94
|
+
content: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class Plan:
|
|
99
|
+
"""One install, as parsed from the command line: where, with what, and how forcefully.
|
|
100
|
+
|
|
101
|
+
A value rather than six parameters threaded through every function here. The grouping is not an
|
|
102
|
+
invention to satisfy an argument-count rule: these are exactly what one invocation decided, they
|
|
103
|
+
travel together to every writer below, and bundling them means a new option is added in one
|
|
104
|
+
place instead of in four signatures.
|
|
105
|
+
|
|
106
|
+
`hook_format` is kiro's and is ignored by the Claude Code target, which has one hook shape. It
|
|
107
|
+
stays on the shared value rather than being pushed into a kiro-only bag because the alternative
|
|
108
|
+
— a per-harness options type — would make `main.py` build its arguments differently depending on
|
|
109
|
+
a flag it has not resolved yet.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
project: Path
|
|
113
|
+
commands: Commands
|
|
114
|
+
model: str
|
|
115
|
+
hook_format: HookFormat
|
|
116
|
+
force: bool
|
|
117
|
+
trust_tools: bool
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(slots=True)
|
|
121
|
+
class Report:
|
|
122
|
+
"""What an install did, for `main.py` to print and for a test to assert on."""
|
|
123
|
+
|
|
124
|
+
created: list[Path] = field(default_factory=list)
|
|
125
|
+
replaced: list[Path] = field(default_factory=list)
|
|
126
|
+
skipped: list[Path] = field(default_factory=list)
|
|
127
|
+
#: The subset of `skipped` left alone because its backup failed, not because it was already
|
|
128
|
+
#: correct. Recorded rather than re-derived: the other two reasons are still readable from the
|
|
129
|
+
#: file, this one is not.
|
|
130
|
+
kept_unbacked: list[Path] = field(default_factory=list)
|
|
131
|
+
merged: list[Path] = field(default_factory=list)
|
|
132
|
+
backed_up: list[Path] = field(default_factory=list)
|
|
133
|
+
notes: list[str] = field(default_factory=list)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class MergePlan:
|
|
138
|
+
"""A merge that has been checked and not yet performed.
|
|
139
|
+
|
|
140
|
+
`document` is the finished JSON to write. Holding it as a value is what lets the caller order
|
|
141
|
+
the whole install "check everything, then write everything" — the checks live in the `plan_*`
|
|
142
|
+
functions, which read and refuse, and every write is in `commit_merge`.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
path: Path
|
|
146
|
+
document: dict[str, object]
|
|
147
|
+
notes: tuple[str, ...]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def guard_shipped_targets(files: tuple[ShippedFile, ...], project: Path) -> None:
|
|
151
|
+
"""Refuse any shipped path whose existing shape would make writing it fail or lie. Writes
|
|
152
|
+
nothing.
|
|
153
|
+
|
|
154
|
+
Two failures this closes, both reachable on an ordinary project and neither previously checked:
|
|
155
|
+
|
|
156
|
+
- A **directory** at `SKILL.md` was treated as an ordinary collision and reported as "kept", so
|
|
157
|
+
the install exited 0 while no loadable skill existed at all.
|
|
158
|
+
- A **regular file** where `.kiro/skills` or the skill's own directory belongs made the later
|
|
159
|
+
`mkdir(parents=True)` raise `NotADirectoryError` — *after* the consolidator config had been
|
|
160
|
+
written. A traceback and a half-install, from a path shape rather than a crash.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
InstallError: a final target exists and is not a regular file, or an ancestor that must be a
|
|
164
|
+
directory is not one.
|
|
165
|
+
"""
|
|
166
|
+
for shipped in files:
|
|
167
|
+
target = shipped.path
|
|
168
|
+
if _occupied(target) and not target.is_file():
|
|
169
|
+
raise InstallError(
|
|
170
|
+
f"{target} is not a regular file, so it cannot be installed. Move it aside and "
|
|
171
|
+
"re-run."
|
|
172
|
+
)
|
|
173
|
+
for ancestor in _ancestors_within(target, project):
|
|
174
|
+
if _occupied(ancestor) and not ancestor.is_dir():
|
|
175
|
+
raise InstallError(
|
|
176
|
+
f"{ancestor} is not a directory, so {target.name} cannot be installed under "
|
|
177
|
+
"it. Move it aside and re-run."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _occupied(path: Path) -> bool:
|
|
182
|
+
"""Whether something is at `path` — a **dangling symlink included**.
|
|
183
|
+
|
|
184
|
+
`Path.exists()` alone is false for a dangling symlink, which is how a dangling `SKILL.md` link
|
|
185
|
+
previously passed the preflight, was reported as "kept", and let the install exit 0 with no
|
|
186
|
+
loadable skill anywhere. The pair of checks below then reads "occupied, and not the kind it must
|
|
187
|
+
be", which is true of a link to the wrong kind of thing as well as of a wrong thing.
|
|
188
|
+
"""
|
|
189
|
+
return path.exists() or path.is_symlink()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _ancestors_within(target: Path, project: Path) -> list[Path]:
|
|
193
|
+
"""`target`'s parent directories, outermost first, stopping at `project`.
|
|
194
|
+
|
|
195
|
+
Bounded at the project, because everything above it belongs to the user's filesystem rather than
|
|
196
|
+
to this install: a check that walked to `/` would refuse on any unusual mount above a perfectly
|
|
197
|
+
ordinary project.
|
|
198
|
+
"""
|
|
199
|
+
ancestors: list[Path] = []
|
|
200
|
+
current = target.parent
|
|
201
|
+
while current not in (project, current.parent):
|
|
202
|
+
ancestors.append(current)
|
|
203
|
+
current = current.parent
|
|
204
|
+
return list(reversed(ancestors))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def write_shipped_files(files: tuple[ShippedFile, ...], plan: Plan, report: Report) -> None:
|
|
208
|
+
"""Write every shipped artefact, recording each outcome in `report`."""
|
|
209
|
+
for shipped in files:
|
|
210
|
+
_write_shipped(shipped, plan=plan, report=report)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _write_shipped(shipped: ShippedFile, *, plan: Plan, report: Report) -> None:
|
|
214
|
+
"""Create the file, refresh it if what is there is not what we ship, or keep and report it.
|
|
215
|
+
|
|
216
|
+
**Staleness is a content comparison**, and that is a fix rather than a simplification. The
|
|
217
|
+
predicate used to ask one narrow question — does this config name a *different install's*
|
|
218
|
+
interpreter — which caught a cloned repository and missed the case it was most likely to meet:
|
|
219
|
+
*same install, older version*. Upgrading Zikaron and re-running the installer left the previous
|
|
220
|
+
version's consolidator prompt in place, reported cheerfully as "already there", unless someone
|
|
221
|
+
thought to pass `--force`. A prompt that no longer matches the tools it describes is exactly the
|
|
222
|
+
confidently-stale artefact this project exists to prevent, and it was being produced by the
|
|
223
|
+
installer's own success path.
|
|
224
|
+
|
|
225
|
+
Comparing the bytes subsumes the old question — a different interpreter yields different content
|
|
226
|
+
— and extends it to every shipped file rather than only the JSON one, which is why kiro's skill
|
|
227
|
+
could previously never be refreshed at all (its staleness predicate was the constant `False`).
|
|
228
|
+
It also catches a hand-edited artefact, which is the one behaviour change: such a file is now
|
|
229
|
+
backed up and rewritten rather than silently kept. That is the right trade for a file the
|
|
230
|
+
installer owns, and it is safe because nothing is replaced without `<name>.bak` existing first.
|
|
231
|
+
|
|
232
|
+
A symlink at the target is treated as an existing file rather than followed, so neither the
|
|
233
|
+
stale path nor `--force` can be talked into writing through a link into somewhere else entirely.
|
|
234
|
+
"""
|
|
235
|
+
path, content = shipped
|
|
236
|
+
exists = path.exists() or path.is_symlink()
|
|
237
|
+
if exists and plan.force and not path.is_symlink() and _read_or_empty(path) != content:
|
|
238
|
+
# **`--force` backs up too.** The content-comparison trade rests on "nothing is replaced
|
|
239
|
+
# without `<name>.bak` existing first", and this path made that false exactly where it
|
|
240
|
+
# matters most: `--force` is the flag the merge-conflict refusals *instruct* people to pass,
|
|
241
|
+
# so it arrives alongside an unrelated conflict rather than only when someone means
|
|
242
|
+
# "discard my edits". Best-effort:
|
|
243
|
+
# a backup that cannot be written must not block an override the user asked for
|
|
244
|
+
# explicitly, so it degrades to a note rather than a refusal.
|
|
245
|
+
try:
|
|
246
|
+
_back_up_once(path, report=report)
|
|
247
|
+
except InstallError as exc:
|
|
248
|
+
report.notes.append(
|
|
249
|
+
f"{path.name} could not be backed up before --force replaced it ({exc})."
|
|
250
|
+
)
|
|
251
|
+
if exists and not plan.force:
|
|
252
|
+
if path.is_symlink() or _read_or_empty(path) == content:
|
|
253
|
+
report.skipped.append(path)
|
|
254
|
+
return
|
|
255
|
+
try:
|
|
256
|
+
_back_up_once(path, report=report)
|
|
257
|
+
except InstallError as exc:
|
|
258
|
+
# A stale artefact is worth correcting, but not at the cost of losing what is there with
|
|
259
|
+
# no copy of it. Keeping a broken consolidator config is recoverable; replacing it
|
|
260
|
+
# unbacked is not, so this reports and leaves it.
|
|
261
|
+
report.skipped.append(path)
|
|
262
|
+
report.kept_unbacked.append(path)
|
|
263
|
+
report.notes.append(
|
|
264
|
+
f"{path.name} differs from what this install ships but could not be backed up "
|
|
265
|
+
f"({exc}), so it was left alone. Move it aside and re-run to get a correct one."
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
_replace(path, content)
|
|
269
|
+
report.replaced.append(path)
|
|
270
|
+
report.notes.append(
|
|
271
|
+
f"{path.name} differed from what this install ships and was rewritten — an older "
|
|
272
|
+
f"Zikaron, another install, or a local edit. The original is at {path.name}.bak."
|
|
273
|
+
)
|
|
274
|
+
return
|
|
275
|
+
if path.is_symlink():
|
|
276
|
+
path.unlink()
|
|
277
|
+
_replace(path, content)
|
|
278
|
+
(report.replaced if exists else report.created).append(path)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _read_or_empty(path: Path) -> str:
|
|
282
|
+
try:
|
|
283
|
+
return path.read_text(encoding="utf-8")
|
|
284
|
+
except OSError:
|
|
285
|
+
return ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _replace(path: Path, content: str) -> None:
|
|
289
|
+
"""Write `content` to `path` atomically, creating parents as needed.
|
|
290
|
+
|
|
291
|
+
Through a temporary file in the same directory and one rename, because for the agent config this
|
|
292
|
+
is a file the user owns: a crash or a full disk part-way through a plain write leaves them with
|
|
293
|
+
a truncated config the harness then refuses to load, and the `.bak` beside it is a worse remedy
|
|
294
|
+
than never having broken it.
|
|
295
|
+
|
|
296
|
+
The scratch file is created **exclusively, under a name nothing can predict**. A fixed
|
|
297
|
+
`<target>.tmp` would be *followed* if it happened to be a symlink — the same hole the check on
|
|
298
|
+
the final target closes one component along, and a destination check that leaves its own scratch
|
|
299
|
+
file
|
|
300
|
+
open to redirection has closed nothing.
|
|
301
|
+
|
|
302
|
+
An existing file's permission bits are carried over, because a rename replaces the inode and its
|
|
303
|
+
mode with it: a config that was `0644` must not come back `0600` as a side effect of how it was
|
|
304
|
+
written.
|
|
305
|
+
"""
|
|
306
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
mode = path.stat().st_mode & 0o777 if path.is_file() and not path.is_symlink() else None
|
|
308
|
+
with _exclusive_temporary(path) as scratch:
|
|
309
|
+
_write_all(scratch.descriptor, content.encode("utf-8"))
|
|
310
|
+
if mode is not None:
|
|
311
|
+
os.fchmod(scratch.descriptor, mode)
|
|
312
|
+
scratch.path.replace(path)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _write_all(descriptor: int, payload: bytes) -> None:
|
|
316
|
+
"""Write every byte of `payload` to `descriptor`, then flush it to the device.
|
|
317
|
+
|
|
318
|
+
A single `os.write` is **not** enough, and that is the point of this function existing: a write
|
|
319
|
+
to a regular file may legally return short — the documented case being a partial write when the
|
|
320
|
+
space for the rest is not there — and ignoring the return value would let a truncated file be
|
|
321
|
+
renamed over a user's config while the install reported success. That is the exact failure the
|
|
322
|
+
atomic rewrite was introduced to close, reintroduced one layer down.
|
|
323
|
+
|
|
324
|
+
`fsync` before the caller publishes, because the rename is atomic against other *processes* and
|
|
325
|
+
says nothing about a crash: without it, a machine that loses power moments later can come back
|
|
326
|
+
with the rename applied and the contents not.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
OSError: any part of the write or the flush failed. Nothing is published, because the caller
|
|
330
|
+
publishes after this returns.
|
|
331
|
+
"""
|
|
332
|
+
written = 0
|
|
333
|
+
while written < len(payload):
|
|
334
|
+
just_written = os.write(descriptor, payload[written:])
|
|
335
|
+
if just_written == 0:
|
|
336
|
+
raise OSError(errno.ENOSPC, "wrote no bytes and none were rejected", None)
|
|
337
|
+
written += just_written
|
|
338
|
+
os.fsync(descriptor)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class _Scratch(NamedTuple):
|
|
342
|
+
"""An open descriptor and the path it was created at, held together.
|
|
343
|
+
|
|
344
|
+
Both, because the writing goes through the **descriptor** while the publication is by path: the
|
|
345
|
+
descriptor is what makes the write immune to the name being swapped underneath it, and the path
|
|
346
|
+
is
|
|
347
|
+
what `replace`/`link` needs.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
descriptor: int
|
|
351
|
+
path: Path
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@contextmanager
|
|
355
|
+
def _exclusive_temporary(destination: Path) -> Iterator[_Scratch]:
|
|
356
|
+
"""A freshly created scratch file beside `destination`, held open, removed on any failure.
|
|
357
|
+
|
|
358
|
+
Beside it rather than under `/tmp`, so the final rename or link stays on one filesystem —
|
|
359
|
+
neither is atomic across devices, and `os.replace` raises. Created through `mkstemp`, which
|
|
360
|
+
opens with `O_CREAT | O_EXCL` under an unpredictable name, so no pre-existing name can
|
|
361
|
+
redirect it.
|
|
362
|
+
|
|
363
|
+
**The descriptor stays open until the publication.** Closing it and reopening the path by name
|
|
364
|
+
would reintroduce exactly the hole the exclusive creation closes: between the close and the
|
|
365
|
+
reopen, anything able to write in that directory could unlink the name and leave a symlink in
|
|
366
|
+
its place, and the reopen would follow it. Writing through the descriptor means the bytes go
|
|
367
|
+
to the inode this function created, whatever happens to the name.
|
|
368
|
+
"""
|
|
369
|
+
descriptor, raw = tempfile.mkstemp(
|
|
370
|
+
dir=destination.parent, prefix=f".{destination.name}.", suffix=".tmp"
|
|
371
|
+
)
|
|
372
|
+
scratch = _Scratch(descriptor=descriptor, path=Path(raw))
|
|
373
|
+
try:
|
|
374
|
+
yield scratch
|
|
375
|
+
finally:
|
|
376
|
+
os.close(descriptor)
|
|
377
|
+
scratch.path.unlink(missing_ok=True)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def plan_kiro_merge(path: Path, plan: Plan, targets: Targets) -> MergePlan:
|
|
381
|
+
"""Check a user's agent config and build the document a merge would write. Writes nothing.
|
|
382
|
+
|
|
383
|
+
The file's own format decides how hooks are written — object stays object, array stays array —
|
|
384
|
+
because the harness rewrites a config in whichever format it read, and a file carrying both
|
|
385
|
+
shapes has no defined meaning. `plan.hook_format` applies only when the file has no `hooks`
|
|
386
|
+
key at all.
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
InstallError: the file is missing, not a JSON object, carries `hooks`/`mcpServers` in a
|
|
390
|
+
shape this cannot merge into, or already carries a Zikaron entry that differs from what
|
|
391
|
+
this install would write. The last is the one worth refusing loudly: it means a
|
|
392
|
+
previous install from a *different* interpreter, whose paths may point at a venv that
|
|
393
|
+
no longer has Zikaron in it. Replacing is almost always right, and doing it silently
|
|
394
|
+
would hide that the user has two installs.
|
|
395
|
+
"""
|
|
396
|
+
document = _load_agent_config(path)
|
|
397
|
+
hook_format = _detect_format(document, path=path, requested=plan.hook_format)
|
|
398
|
+
_guard_mergeable_shapes(document, path=path)
|
|
399
|
+
_guard_existing_entries(document, plan.commands, path=path, force=plan.force)
|
|
400
|
+
guard_backup_path(path)
|
|
401
|
+
|
|
402
|
+
merged = dict(document)
|
|
403
|
+
merged["hooks"] = _merged_hooks(document, plan.commands, hook_format)
|
|
404
|
+
merged["mcpServers"] = _merged_servers(document, plan.commands)
|
|
405
|
+
merged["tools"] = _selecting(document, "tools")
|
|
406
|
+
if plan.trust_tools:
|
|
407
|
+
merged["allowedTools"] = _selecting(document, "allowedTools")
|
|
408
|
+
settings, crew_notes = _merged_tools_settings(document)
|
|
409
|
+
if settings is not None:
|
|
410
|
+
merged["toolsSettings"] = settings
|
|
411
|
+
resources, resource_notes = _merged_resources(document, targets)
|
|
412
|
+
if resources is not None:
|
|
413
|
+
merged["resources"] = resources
|
|
414
|
+
|
|
415
|
+
notes = [ARRAY_FORMAT_OUTPUT_CAP_NOTE] if hook_format is HookFormat.ARRAY else []
|
|
416
|
+
notes.extend(crew_notes)
|
|
417
|
+
notes.extend(resource_notes)
|
|
418
|
+
if TOOL_SELECTOR not in _string_list(document.get("tools")):
|
|
419
|
+
notes.append(
|
|
420
|
+
f"Added `{TOOL_SELECTOR}` to {path.name}'s `tools` — without it Zikaron's tools are "
|
|
421
|
+
"simply absent, since `mcpServers` configures the server and `tools` selects from it."
|
|
422
|
+
)
|
|
423
|
+
if not plan.trust_tools and TOOL_SELECTOR not in _string_list(document.get("allowedTools")):
|
|
424
|
+
notes.append(
|
|
425
|
+
f"`{TOOL_SELECTOR}` is **not** in {path.name}'s `allowedTools` (--no-trust-tools), so "
|
|
426
|
+
"every Zikaron tool call asks your permission. Remove that flag, or add it by hand, to "
|
|
427
|
+
"let the agent record without interrupting you."
|
|
428
|
+
)
|
|
429
|
+
if "subagent" not in _string_list(document.get("tools")):
|
|
430
|
+
notes.append(
|
|
431
|
+
f"{path.name} does not list the `subagent` tool. The zikaron-consolidate skill spawns "
|
|
432
|
+
"the consolidator through it, so add it to `tools` to be able to consolidate. Granting "
|
|
433
|
+
"a built-in tool is your decision, so this install does not do it for you."
|
|
434
|
+
)
|
|
435
|
+
return MergePlan(path=path, document=merged, notes=tuple(notes))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def commit_merge(merge: MergePlan, report: Report) -> None:
|
|
439
|
+
"""Back the config up if nothing is backed up yet, then write the planned document.
|
|
440
|
+
|
|
441
|
+
**A file that does not exist yet is not backed up**, and that is not merely an optimisation: a
|
|
442
|
+
backup is a copy of the user's own prior state, and there is none. Kiro never reaches this
|
|
443
|
+
branch — `--agent` must name an existing file and `main.py` refuses otherwise — but Claude
|
|
444
|
+
Code's two merge targets are fixed project paths that a fresh project simply does not have, and
|
|
445
|
+
attempting a copy there would refuse the whole install over the absence of a file we are about
|
|
446
|
+
to create.
|
|
447
|
+
"""
|
|
448
|
+
if merge.path.exists() or merge.path.is_symlink():
|
|
449
|
+
_back_up_once(merge.path, report=report)
|
|
450
|
+
_replace(merge.path, json.dumps(merge.document, indent=_JSON_INDENT) + "\n")
|
|
451
|
+
report.merged.append(merge.path)
|
|
452
|
+
report.notes.extend(merge.notes)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _load_agent_config(path: Path) -> dict[str, object]:
|
|
456
|
+
try:
|
|
457
|
+
raw = path.read_text(encoding="utf-8")
|
|
458
|
+
except OSError as exc:
|
|
459
|
+
raise InstallError(f"could not read {path}: {exc}") from exc
|
|
460
|
+
try:
|
|
461
|
+
document = json.loads(raw)
|
|
462
|
+
except json.JSONDecodeError as exc:
|
|
463
|
+
raise InstallError(f"{path} is not valid JSON: {exc}") from exc
|
|
464
|
+
if not isinstance(document, dict):
|
|
465
|
+
raise InstallError(f"{path} is not a JSON object")
|
|
466
|
+
return document
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _detect_format(document: dict[str, object], *, path: Path, requested: HookFormat) -> HookFormat:
|
|
470
|
+
"""Which format this file's hooks are in, refusing a shape that is neither.
|
|
471
|
+
|
|
472
|
+
A present-but-unmergeable `hooks` value is refused rather than replaced: it is data the user put
|
|
473
|
+
there, this code cannot tell what they meant by it, and silently dropping it to install our own
|
|
474
|
+
two entries would destroy something to make room for something.
|
|
475
|
+
"""
|
|
476
|
+
hooks = document.get("hooks")
|
|
477
|
+
if isinstance(hooks, dict):
|
|
478
|
+
return HookFormat.OBJECT
|
|
479
|
+
if isinstance(hooks, list):
|
|
480
|
+
return HookFormat.ARRAY
|
|
481
|
+
if "hooks" in document:
|
|
482
|
+
raise InstallError(
|
|
483
|
+
f"{path} has a `hooks` value that is neither an object nor an array "
|
|
484
|
+
f"({type(hooks).__name__}). Fix or remove it before installing; this refuses rather "
|
|
485
|
+
"than replace it."
|
|
486
|
+
)
|
|
487
|
+
return requested
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _guard_mergeable_shapes(document: dict[str, object], *, path: Path) -> None:
|
|
491
|
+
"""Refuse any `hooks`/`tools` shape the merge below would silently drop.
|
|
492
|
+
|
|
493
|
+
The merge is written defensively — it filters a hook-map value that is not a list, and treats a
|
|
494
|
+
non-list `tools` as absent — and defensive filtering on a *write* path is data loss with a
|
|
495
|
+
reassuring shape. A trigger whose value is an object, or a `tools` value that is a string, is
|
|
496
|
+
something the user put there; this cannot know what they meant by it, and installing over it
|
|
497
|
+
would destroy something to make room for something.
|
|
498
|
+
"""
|
|
499
|
+
hooks = document.get("hooks")
|
|
500
|
+
if isinstance(hooks, dict):
|
|
501
|
+
for trigger, value in hooks.items():
|
|
502
|
+
if not isinstance(value, list):
|
|
503
|
+
raise InstallError(
|
|
504
|
+
f"{path} has `hooks.{trigger}` as {type(value).__name__} rather than an array. "
|
|
505
|
+
"Fix or remove it before installing; this refuses rather than drop it."
|
|
506
|
+
)
|
|
507
|
+
for key in ("tools", "allowedTools"):
|
|
508
|
+
value = document.get(key)
|
|
509
|
+
if key in document and not isinstance(value, list):
|
|
510
|
+
raise InstallError(
|
|
511
|
+
f"{path} has a `{key}` value that is not an array ({type(value).__name__}). Fix or "
|
|
512
|
+
"remove it before installing; this refuses rather than replace it."
|
|
513
|
+
)
|
|
514
|
+
if "resources" in document and not isinstance(document.get("resources"), list):
|
|
515
|
+
raise InstallError(
|
|
516
|
+
f"{path} has a `resources` value that is not an array "
|
|
517
|
+
f"({type(document.get('resources')).__name__}). Fix or remove it before installing."
|
|
518
|
+
)
|
|
519
|
+
_guard_crew_shape(document, path=path)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _guard_crew_shape(document: dict[str, object], *, path: Path) -> None:
|
|
523
|
+
"""Refuse a `toolsSettings.crew` this cannot merge into rather than replacing it.
|
|
524
|
+
|
|
525
|
+
Same rule as every other shape guard here, one level deeper: the crew block gates which agents
|
|
526
|
+
may be spawned, so quietly rebuilding a malformed one could hand the user a config that spawns
|
|
527
|
+
more — or less — than they had written down.
|
|
528
|
+
"""
|
|
529
|
+
settings = document.get("toolsSettings")
|
|
530
|
+
if "toolsSettings" in document and not isinstance(settings, dict):
|
|
531
|
+
raise InstallError(
|
|
532
|
+
f"{path} has a `toolsSettings` value that is not an object "
|
|
533
|
+
f"({type(settings).__name__}). Fix or remove it before installing."
|
|
534
|
+
)
|
|
535
|
+
if not isinstance(settings, dict):
|
|
536
|
+
return
|
|
537
|
+
present = [key for key in _CREW_KEYS if key in settings]
|
|
538
|
+
if len(present) > 1:
|
|
539
|
+
raise InstallError(
|
|
540
|
+
f"{path} has both `toolsSettings.crew` and `toolsSettings.agent_crew`. The harness "
|
|
541
|
+
"documents them as aliases for one block and gives no meaning to carrying both, so "
|
|
542
|
+
"this refuses rather than guess which one it reads. Keep one and re-run."
|
|
543
|
+
)
|
|
544
|
+
for crew_key in present:
|
|
545
|
+
crew = settings[crew_key]
|
|
546
|
+
if not isinstance(crew, dict):
|
|
547
|
+
raise InstallError(
|
|
548
|
+
f"{path} has a `toolsSettings.{crew_key}` value that is not an object "
|
|
549
|
+
f"({type(crew).__name__}). Fix or remove it before installing."
|
|
550
|
+
)
|
|
551
|
+
for list_key in ("availableAgents", "trustedAgents"):
|
|
552
|
+
if list_key in crew and not isinstance(crew[list_key], list):
|
|
553
|
+
raise InstallError(
|
|
554
|
+
f"{path} has `toolsSettings.{crew_key}.{list_key}` as "
|
|
555
|
+
f"{type(crew[list_key]).__name__} rather than an array. Fix or remove it "
|
|
556
|
+
"before installing."
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _guard_existing_entries(
|
|
561
|
+
document: dict[str, object], commands: Commands, *, path: Path, force: bool
|
|
562
|
+
) -> None:
|
|
563
|
+
"""Refuse a config already wired to a *different* Zikaron install, unless forced.
|
|
564
|
+
|
|
565
|
+
Both halves are checked, and the hook half was the gap: an earlier version guarded only the
|
|
566
|
+
`mcpServers` entry, so a config carrying a stale hook command and no server entry had that
|
|
567
|
+
command silently replaced. The two are installed together and must be refused together.
|
|
568
|
+
"""
|
|
569
|
+
servers = document.get("mcpServers")
|
|
570
|
+
if "mcpServers" in document and not isinstance(servers, dict):
|
|
571
|
+
raise InstallError(
|
|
572
|
+
f"{path} has an `mcpServers` value that is not an object "
|
|
573
|
+
f"({type(servers).__name__}). Fix or remove it before installing."
|
|
574
|
+
)
|
|
575
|
+
if force:
|
|
576
|
+
return
|
|
577
|
+
has_server = isinstance(servers, dict) and MCP_SERVER_NAME in servers
|
|
578
|
+
existing_server = servers.get(MCP_SERVER_NAME) if isinstance(servers, dict) else None
|
|
579
|
+
expected_server = mcp_servers_value(commands, mode="primary")[MCP_SERVER_NAME]
|
|
580
|
+
if has_server and existing_server != expected_server:
|
|
581
|
+
raise InstallError(
|
|
582
|
+
f"{path} already has an `mcpServers.{MCP_SERVER_NAME}` entry pointing somewhere else "
|
|
583
|
+
f"({existing_server!r}). That is a previous install from a different interpreter. "
|
|
584
|
+
"Re-run with --force to replace it."
|
|
585
|
+
)
|
|
586
|
+
differing = _differing_zikaron_hooks(document, commands)
|
|
587
|
+
if differing:
|
|
588
|
+
raise InstallError(
|
|
589
|
+
f"{path} already has Zikaron hook entries that differ from what this install would "
|
|
590
|
+
f"write ({'; '.join(differing)}). That is either a previous install from a different "
|
|
591
|
+
"interpreter or an entry you edited. Re-run with --force to adopt this install's "
|
|
592
|
+
"entries."
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _differing_zikaron_hooks(document: dict[str, object], commands: Commands) -> list[str]:
|
|
597
|
+
"""Every existing Zikaron hook entry that is not **exactly** what this install would write.
|
|
598
|
+
|
|
599
|
+
The contract's word is *differs*, and equality of one field is not equality of an entry: an
|
|
600
|
+
entry carrying the current command with `timeout_ms: 1`, or the reserved name with a changed
|
|
601
|
+
`trigger`, is something a person chose, and the merge below would replace it. So the comparison
|
|
602
|
+
is structural, against the generated entry for that entry's own trigger.
|
|
603
|
+
|
|
604
|
+
Recognition uses **both** identities the merge uses — an object-format entry by its command's
|
|
605
|
+
file name, an array-format entry by its reserved `name` — because guarding one while replacing
|
|
606
|
+
on either is how an entry comes to be rewritten silently.
|
|
607
|
+
|
|
608
|
+
Returned as descriptions rather than a boolean so the refusal can say *what* differs. That is
|
|
609
|
+
the only way a user can tell their own edit from a Zikaron version change, and therefore
|
|
610
|
+
whether
|
|
611
|
+
`--force` is the right answer.
|
|
612
|
+
"""
|
|
613
|
+
hooks = document.get("hooks")
|
|
614
|
+
if isinstance(hooks, dict):
|
|
615
|
+
expected_by_trigger = {
|
|
616
|
+
trigger: entries[0] for trigger, entries in hooks_object(commands).items()
|
|
617
|
+
}
|
|
618
|
+
described = [
|
|
619
|
+
_describe_difference(entry, expected_by_trigger.get(trigger), where=trigger)
|
|
620
|
+
for trigger, entries in hooks.items()
|
|
621
|
+
if isinstance(entries, list)
|
|
622
|
+
for entry in entries
|
|
623
|
+
if _is_our_command(entry, commands)
|
|
624
|
+
]
|
|
625
|
+
return [description for description in described if description is not None]
|
|
626
|
+
if not isinstance(hooks, list):
|
|
627
|
+
return []
|
|
628
|
+
expected_by_name = {entry["name"]: entry for entry in hooks_array(commands)}
|
|
629
|
+
descriptions: list[str] = []
|
|
630
|
+
for entry in hooks:
|
|
631
|
+
name = entry.get("name") if isinstance(entry, dict) else None
|
|
632
|
+
named = isinstance(name, str) and name in expected_by_name
|
|
633
|
+
if not (named or _is_our_command(entry, commands)):
|
|
634
|
+
continue
|
|
635
|
+
expected = expected_by_name.get(name) if isinstance(name, str) else None
|
|
636
|
+
description = _describe_difference(entry, expected, where=str(name or "an unnamed entry"))
|
|
637
|
+
if description is not None:
|
|
638
|
+
descriptions.append(description)
|
|
639
|
+
return descriptions
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _describe_difference(entry: object, expected: object, *, where: str) -> str | None:
|
|
643
|
+
"""`None` if `entry` is exactly `expected`, else a short account of how the two differ."""
|
|
644
|
+
if entry == expected:
|
|
645
|
+
return None
|
|
646
|
+
if expected is None:
|
|
647
|
+
return f"{where}: not an entry this install writes"
|
|
648
|
+
if not isinstance(entry, dict) or not isinstance(expected, dict):
|
|
649
|
+
return f"{where}: {entry!r}"
|
|
650
|
+
differing = [
|
|
651
|
+
key for key in sorted(set(entry) | set(expected)) if entry.get(key) != expected.get(key)
|
|
652
|
+
]
|
|
653
|
+
return f"{where}: {', '.join(differing)}"
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _entry_command(entry: object) -> str | None:
|
|
657
|
+
"""The executable an entry runs, in either format's spelling, **unquoted**.
|
|
658
|
+
|
|
659
|
+
Unquoted because the stored string is shell source: what we write is `shlex.quote`'d, so the raw
|
|
660
|
+
value of a Zikaron entry is `'/path/to/zikaron-hook'` and a caller comparing `Path(...).name`
|
|
661
|
+
against `zikaron-hook` would be comparing against `zikaron-hook'`. A single-token command is
|
|
662
|
+
reported as that token; anything else — a pipeline, several words, unbalanced quotes — is
|
|
663
|
+
reported verbatim, since it is not a bare executable path and no caller should treat it as one.
|
|
664
|
+
"""
|
|
665
|
+
if not isinstance(entry, dict):
|
|
666
|
+
return None
|
|
667
|
+
direct = entry.get("command")
|
|
668
|
+
if not isinstance(direct, str):
|
|
669
|
+
action = entry.get("action")
|
|
670
|
+
nested = action.get("command") if isinstance(action, dict) else None
|
|
671
|
+
direct = nested if isinstance(nested, str) else None
|
|
672
|
+
if direct is None:
|
|
673
|
+
return None
|
|
674
|
+
try:
|
|
675
|
+
words = shlex.split(direct)
|
|
676
|
+
except ValueError:
|
|
677
|
+
# Unbalanced quotes: not something to reinterpret, and the caller compares it as-is.
|
|
678
|
+
return direct
|
|
679
|
+
return words[0] if len(words) == 1 else direct
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def guard_backup_path(path: Path) -> None:
|
|
683
|
+
"""Refuse now if this file could not be backed up later. Creates nothing.
|
|
684
|
+
|
|
685
|
+
Split out of `_back_up_once` and called from `plan_merge` because the backup is the **last**
|
|
686
|
+
thing a merge does and the shipped files are written before it: leaving this check where it
|
|
687
|
+
happens would mean a blocked backup path was discovered only after two files had landed, which
|
|
688
|
+
is the half-install the plan/commit split exists to prevent. The same condition is re-tested
|
|
689
|
+
at commit, because a check and a use are two moments and this one is cheap.
|
|
690
|
+
|
|
691
|
+
Raises:
|
|
692
|
+
InstallError: something is at `<path>.bak` and it is not a regular file.
|
|
693
|
+
"""
|
|
694
|
+
backup = path.with_name(path.name + ".bak")
|
|
695
|
+
if backup.is_file() and not backup.is_symlink():
|
|
696
|
+
return
|
|
697
|
+
if backup.exists() or backup.is_symlink():
|
|
698
|
+
raise InstallError(
|
|
699
|
+
f"{backup} exists and is not a regular file, so {path.name} cannot be backed up. "
|
|
700
|
+
"Move it aside and re-run."
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _back_up_once(path: Path, *, report: Report) -> None:
|
|
705
|
+
"""Copy `path` to `<path>.bak`, unless something is already there.
|
|
706
|
+
|
|
707
|
+
The check is `is_file() and not is_symlink()`, not `exists()`, and both halves earn their keep.
|
|
708
|
+
`exists()` is **false** for a dangling symlink while `shutil.copy2` happily follows one, which
|
|
709
|
+
would write this config's contents to whatever the link names, outside the project entirely. And
|
|
710
|
+
anything at that path which is *not* a regular file — a directory, a fifo — is not a backup, so
|
|
711
|
+
treating its presence as "already backed up" would let a merge proceed with no recoverable copy.
|
|
712
|
+
|
|
713
|
+
Raises:
|
|
714
|
+
InstallError: the copy failed. A merge that cannot be backed up is refused rather than
|
|
715
|
+
performed, because an edit to a file the user owns with no recoverable copy of it is the
|
|
716
|
+
one outcome this whole function exists to prevent.
|
|
717
|
+
"""
|
|
718
|
+
backup = path.with_name(path.name + ".bak")
|
|
719
|
+
if backup.is_file() and not backup.is_symlink():
|
|
720
|
+
report.notes.append(f"{backup.name} already existed and was left as it was.")
|
|
721
|
+
return
|
|
722
|
+
guard_backup_path(path)
|
|
723
|
+
try:
|
|
724
|
+
_copy_atomically(path, backup)
|
|
725
|
+
except FileExistsError:
|
|
726
|
+
# Another process published the first backup between the check above and this link. That
|
|
727
|
+
# copy wins, exactly as an already-present one would have.
|
|
728
|
+
report.notes.append(f"{backup.name} appeared while backing up and was left as it was.")
|
|
729
|
+
return
|
|
730
|
+
except OSError as exc:
|
|
731
|
+
raise InstallError(f"could not back {path} up to {backup.name}: {exc}") from exc
|
|
732
|
+
report.backed_up.append(backup)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _copy_atomically(source: Path, destination: Path) -> None:
|
|
736
|
+
"""Copy `source` to `destination` through an exclusively created temporary file.
|
|
737
|
+
|
|
738
|
+
Not `shutil.copy2` straight to the destination: that writes the final `.bak` in place, so a copy
|
|
739
|
+
interrupted part-way — a full disk, a killed process — leaves a *partial regular file* that the
|
|
740
|
+
next run would accept as the pristine first backup. A rename either happened or did not.
|
|
741
|
+
"""
|
|
742
|
+
with _exclusive_temporary(destination) as scratch:
|
|
743
|
+
_write_all(scratch.descriptor, source.read_bytes())
|
|
744
|
+
shutil.copystat(source, scratch.path)
|
|
745
|
+
# `os.link`, not `replace`: a rename would overwrite a backup another process created
|
|
746
|
+
# between `_guard_backup_path` and here, and "the first backup wins" is the whole rule. A
|
|
747
|
+
# hard link fails with `EEXIST` instead, and the temporary is removed either way.
|
|
748
|
+
os.link(scratch.path, destination)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _merged_hooks(
|
|
752
|
+
document: dict[str, object], commands: Commands, hook_format: HookFormat
|
|
753
|
+
) -> object:
|
|
754
|
+
"""This install's hook entries, folded into whatever the config already had.
|
|
755
|
+
|
|
756
|
+
Replacement is keyed on identity rather than appended blindly: an object-format entry is
|
|
757
|
+
identified by its `command`'s file name, an array-format entry by its `name`. Appending instead
|
|
758
|
+
would give a twice-installed project two hooks per trigger, and therefore two injected blocks
|
|
759
|
+
per
|
|
760
|
+
user message.
|
|
761
|
+
"""
|
|
762
|
+
if hook_format is HookFormat.OBJECT:
|
|
763
|
+
return _merged_hooks_object(document, commands)
|
|
764
|
+
return _merged_hooks_array(document, commands)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _merged_hooks_object(
|
|
768
|
+
document: dict[str, object], commands: Commands
|
|
769
|
+
) -> dict[str, list[object]]:
|
|
770
|
+
existing = document.get("hooks")
|
|
771
|
+
merged: dict[str, list[object]] = (
|
|
772
|
+
{key: list(value) for key, value in existing.items() if isinstance(value, list)}
|
|
773
|
+
if isinstance(existing, dict)
|
|
774
|
+
else {}
|
|
775
|
+
)
|
|
776
|
+
for trigger, entries in hooks_object(commands).items():
|
|
777
|
+
kept = [entry for entry in merged.get(trigger, []) if not _is_our_command(entry, commands)]
|
|
778
|
+
merged[trigger] = [*kept, *entries]
|
|
779
|
+
return merged
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _merged_hooks_array(document: dict[str, object], commands: Commands) -> list[object]:
|
|
783
|
+
ours = hooks_array(commands)
|
|
784
|
+
our_names = {entry["name"] for entry in ours}
|
|
785
|
+
existing = document.get("hooks")
|
|
786
|
+
kept = [
|
|
787
|
+
entry
|
|
788
|
+
for entry in (existing if isinstance(existing, list) else [])
|
|
789
|
+
if not (isinstance(entry, dict) and entry.get("name") in our_names)
|
|
790
|
+
and not _is_our_command(entry, commands)
|
|
791
|
+
]
|
|
792
|
+
return [*kept, *ours]
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _merged_servers(document: dict[str, object], commands: Commands) -> dict[str, object]:
|
|
796
|
+
servers = document.get("mcpServers")
|
|
797
|
+
merged: dict[str, object] = dict(servers) if isinstance(servers, dict) else {}
|
|
798
|
+
merged.update(mcp_servers_value(commands, mode="primary"))
|
|
799
|
+
return merged
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _merged_resources(
|
|
803
|
+
document: dict[str, object], targets: Targets
|
|
804
|
+
) -> tuple[list[object] | None, list[str]]:
|
|
805
|
+
"""The agent's `resources` with the skill declared, when declaring it is what makes it load.
|
|
806
|
+
|
|
807
|
+
Skills reach an agent through `resources`, and by default they *also* arrive without one: the
|
|
808
|
+
harness appends inherited resources (workspace skills among them) to whatever a config declares,
|
|
809
|
+
and only the `chat.disableInheritingDefaultResources` setting turns that off.
|
|
810
|
+
|
|
811
|
+
**The entry is written whenever nothing already covers the skill, including when `resources` is
|
|
812
|
+
absent.** An earlier version only left a note in that case, reasoning that inheritance covers
|
|
813
|
+
it — which leaves a *deterministically broken* install for the one documented configuration
|
|
814
|
+
where it does not, and a warning is not a delivered skill. Declaring resources does not disable
|
|
815
|
+
inheritance (the harness appends to what a config declares), so the entry is purely additive and
|
|
816
|
+
cannot narrow anything; the only cost is a redundant declaration in the common case, against a
|
|
817
|
+
skill that silently fails to load in the uncommon one.
|
|
818
|
+
|
|
819
|
+
A `skill://.kiro/skills/**/SKILL.md` glob already covers it, so a config carrying one gains
|
|
820
|
+
nothing.
|
|
821
|
+
|
|
822
|
+
Coverage is tested with `fnmatch`, whose `*` spans separators and so treats the `**` glob above
|
|
823
|
+
as matching. Both directions of error are benign: over-matching leaves the entry off a config
|
|
824
|
+
that inherits it anyway, and under-matching adds one that is redundant.
|
|
825
|
+
"""
|
|
826
|
+
declared = document.get("resources")
|
|
827
|
+
listed: list[object] = list(declared) if isinstance(declared, list) else []
|
|
828
|
+
target = str(targets.skill_file.relative_to(targets.project))
|
|
829
|
+
covered = any(
|
|
830
|
+
isinstance(entry, str)
|
|
831
|
+
and entry.startswith(_SKILL_SCHEME)
|
|
832
|
+
and fnmatch(target, entry.removeprefix(_SKILL_SCHEME))
|
|
833
|
+
for entry in listed
|
|
834
|
+
)
|
|
835
|
+
if covered:
|
|
836
|
+
return None, []
|
|
837
|
+
return [*listed, _skill_resource(targets)], [
|
|
838
|
+
f"Added `{_skill_resource(targets)}` to `resources`, so the consolidation skill is "
|
|
839
|
+
"reachable whether or not inherited resources are switched on."
|
|
840
|
+
]
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _skill_resource(targets: Targets) -> str:
|
|
844
|
+
"""The `skill://` URI for the shipped skill, relative to the project."""
|
|
845
|
+
return _SKILL_SCHEME + str(targets.skill_file.relative_to(targets.project))
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
#: `toolsSettings.crew` gates which agents the `subagent` tool may spawn. `agent_crew` is a
|
|
849
|
+
#: documented alias for the same block, so a config using it must be *updated in place* rather than
|
|
850
|
+
#: given a second block whose relationship to the first is undefined.
|
|
851
|
+
_CREW_KEYS: Final = ("crew", "agent_crew")
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _merged_tools_settings(
|
|
855
|
+
document: dict[str, object],
|
|
856
|
+
) -> tuple[dict[str, object] | None, list[str]]:
|
|
857
|
+
"""The agent's `toolsSettings` with the consolidator added to its crew, and what to report.
|
|
858
|
+
|
|
859
|
+
**Without this the consolidation skill cannot run at all**, which is how the omission was
|
|
860
|
+
found — in real use, not in review. The skill spawns `zikaron-consolidator` through the
|
|
861
|
+
`subagent` tool, and `toolsSettings.crew.availableAgents` gates which agents that tool may
|
|
862
|
+
spawn: a config listing three of its own agents and not ours answers "Agents not available for
|
|
863
|
+
crew stages".
|
|
864
|
+
|
|
865
|
+
**The two lists are not symmetric, and treating them alike would break working configs.**
|
|
866
|
+
`availableAgents` is a *restriction*: the harness documents that an absent or empty list means
|
|
867
|
+
every agent is available. So adding our name to an empty one would *narrow* the config from "any
|
|
868
|
+
agent" to "only the consolidator" and silently break every other subagent the user has. It is
|
|
869
|
+
therefore extended only when it already lists something. **`trustedAgents` is left alone
|
|
870
|
+
entirely** — it grants a *subagent spawn*, which is wider than Zikaron's own tools, so the
|
|
871
|
+
install **reports** its absence and leaves the grant to the operator.
|
|
872
|
+
|
|
873
|
+
*This read "it is created if absent", which is the opposite of what the body does and of a
|
|
874
|
+
deliberate decision: the function only reads that key, to append a note; three tests assert it
|
|
875
|
+
is never written (`test_install_writer.py`, `test_install_main.py`); and `README.md` §Install
|
|
876
|
+
says the installer "never adds it to `trustedAgents`". **A maintainer reading this docstring as
|
|
877
|
+
the specification would find no such code, call it a bug, and silently grant the consolidator a
|
|
878
|
+
spawn permission the design withholds on purpose.***
|
|
879
|
+
|
|
880
|
+
Returns `(None, notes)` when there is nothing to change, so an install does not add an empty
|
|
881
|
+
`toolsSettings` block to a config that had none.
|
|
882
|
+
"""
|
|
883
|
+
settings = document.get("toolsSettings")
|
|
884
|
+
if settings is None:
|
|
885
|
+
settings = {}
|
|
886
|
+
if not isinstance(settings, dict):
|
|
887
|
+
return None, []
|
|
888
|
+
crew_key = next((key for key in _CREW_KEYS if key in settings), "crew")
|
|
889
|
+
crew = settings.get(crew_key)
|
|
890
|
+
crew_dict: dict[str, object] = dict(crew) if isinstance(crew, dict) else {}
|
|
891
|
+
|
|
892
|
+
notes: list[str] = []
|
|
893
|
+
changed = False
|
|
894
|
+
|
|
895
|
+
available = crew_dict.get("availableAgents")
|
|
896
|
+
if isinstance(available, list) and available:
|
|
897
|
+
if CONSOLIDATOR_AGENT_NAME not in _string_list(available):
|
|
898
|
+
crew_dict["availableAgents"] = [*available, CONSOLIDATOR_AGENT_NAME]
|
|
899
|
+
changed = True
|
|
900
|
+
notes.append(
|
|
901
|
+
f"Added `{CONSOLIDATOR_AGENT_NAME}` to "
|
|
902
|
+
f"`toolsSettings.{crew_key}.availableAgents` — the consolidation skill spawns it "
|
|
903
|
+
"through the `subagent` tool, and that list gates which agents may be spawned."
|
|
904
|
+
)
|
|
905
|
+
else:
|
|
906
|
+
notes.append(
|
|
907
|
+
f"`toolsSettings.{crew_key}.availableAgents` is unset, which the harness reads as "
|
|
908
|
+
"*every* agent being available, so it was left alone rather than narrowed to one entry."
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
if CONSOLIDATOR_AGENT_NAME not in _string_list(crew_dict.get("trustedAgents")):
|
|
912
|
+
notes.append(
|
|
913
|
+
f"Starting a consolidation will ask your permission once, because "
|
|
914
|
+
f"`{CONSOLIDATOR_AGENT_NAME}` is not in `toolsSettings.{crew_key}.trustedAgents`. Add "
|
|
915
|
+
"it there if you would rather it did not — that grants a subagent spawn, which is a "
|
|
916
|
+
"wider thing than Zikaron's tools and so is left to you."
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
if not changed:
|
|
920
|
+
return None, notes
|
|
921
|
+
merged_settings = dict(settings)
|
|
922
|
+
merged_settings[crew_key] = crew_dict
|
|
923
|
+
return merged_settings, notes
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _selecting(document: dict[str, object], key: str) -> list[object]:
|
|
927
|
+
"""The agent's `tools` or `allowedTools`, with `@zikaron` added if it is not already there.
|
|
928
|
+
|
|
929
|
+
**`tools` is not optional, and that was measured rather than assumed.** An agent carrying the
|
|
930
|
+
`mcpServers` entry and a `tools` list that did not name the server reported its own tools as
|
|
931
|
+
`code, dummy, execute_bash, fs_read, fs_write, glob, grep, todo_list, use_subagent` — every
|
|
932
|
+
memory tool absent, no warning anywhere. `mcpServers` configures a server; `tools` selects
|
|
933
|
+
from it.
|
|
934
|
+
|
|
935
|
+
**`allowedTools` is added too, by default, and the reason is the write policy rather than
|
|
936
|
+
convenience.** Without it, every `remember`, `amend` and `retire` interrupts the user for
|
|
937
|
+
approval, and the design's whole write posture is "err toward recording" — the prior art's own
|
|
938
|
+
measured failure was an agent that recorded too *little*. Per-write friction pushes directly
|
|
939
|
+
against the one behaviour the store depends on, and it trains a user to click through prompts,
|
|
940
|
+
which is worse than
|
|
941
|
+
not asking at all.
|
|
942
|
+
|
|
943
|
+
What is being trusted is narrow, and worth stating precisely rather than generously: tools that
|
|
944
|
+
read and write rows in SQLite files under the project, with no network and no effect outside
|
|
945
|
+
it. A mistaken write is **recoverable, not undoable** — `retire` withdraws a row from
|
|
946
|
+
ordinary retrieval and leaves it auditable, while an `amend` overwrites prose that nothing
|
|
947
|
+
restores. That is a weaker guarantee than "reversible", which is the word this used first and
|
|
948
|
+
which overstated it. `--no-trust-tools` leaves the entry out for anyone who wants the prompts,
|
|
949
|
+
and the install reports which it did either way.
|
|
950
|
+
"""
|
|
951
|
+
existing = document.get(key)
|
|
952
|
+
listed: list[object] = list(existing) if isinstance(existing, list) else []
|
|
953
|
+
if TOOL_SELECTOR in _string_list(existing):
|
|
954
|
+
return listed
|
|
955
|
+
return [*listed, TOOL_SELECTOR]
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _is_our_command(entry: object, commands: Commands) -> bool:
|
|
959
|
+
"""Whether an existing entry is a Zikaron hook, in either format's spelling.
|
|
960
|
+
|
|
961
|
+
Matches on the command's **file name**, not the whole path, so a previous install from another
|
|
962
|
+
venv is replaced rather than left beside this one — two `zikaron-hook` entries on one trigger
|
|
963
|
+
would inject two blocks per message, and the stale one would point at an interpreter that may no
|
|
964
|
+
longer have Zikaron installed.
|
|
965
|
+
"""
|
|
966
|
+
command = _entry_command(entry)
|
|
967
|
+
return command is not None and Path(command).name == commands.hook.name
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _string_list(value: object) -> frozenset[str]:
|
|
971
|
+
if not isinstance(value, list):
|
|
972
|
+
return frozenset()
|
|
973
|
+
return frozenset(item for item in value if isinstance(item, str))
|