Skip to content

feat: Add semble install#176

Open
Pringled wants to merge 12 commits into
mainfrom
add-semble-install
Open

feat: Add semble install#176
Pringled wants to merge 12 commits into
mainfrom
add-semble-install

Conversation

@Pringled
Copy link
Copy Markdown
Member

@Pringled Pringled commented Jun 2, 2026

This PR adds a semble install command.

@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 2, 2026

Codecov Report

❌ Patch coverage is 73.07692% with 91 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/semble/installer.py 72.25% 91 Missing ⚠️
Files with missing lines Coverage Δ
src/semble/cli.py 100.00% <100.00%> (ø)
src/semble/installer.py 72.25% <72.25%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Pringled
Copy link
Copy Markdown
Member Author

Pringled commented Jun 2, 2026

@greptileai review

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 2, 2026

Confidence Score: 4/5

Safe to merge; the installer reads and modifies only well-scoped agent config files, the JSON/JSONC path uses a verified reparse guard before writing, and the interactive confirmation step protects against accidental changes.

The core file-manipulation logic is well-tested and the reparse guard prevents writing malformed JSON. Two small rough edges: the apply output treats already-configured integrations identically to unsupported ones (dim dash), which could confuse users re-running the installer, and _apply_subagent propagates a raw FileNotFoundError if a package-data template is missing rather than returning the 'error' action used elsewhere.

src/semble/installer.py — specifically the _apply output formatting for the 'unchanged' action and the missing error-handling in _apply_subagent.

Reviews (1): Last reviewed commit: "Group types/vars" | Re-trigger Greptile

Comment thread src/semble/installer.py
Comment on lines +601 to +608
result = integ.apply(agent, mode)
if result is None:
print(f" {_DIM}– {integ.id}: not supported{_RESET}")
continue
ok = result.action in ("created", "updated", "removed")
detail = _ACTION_DETAIL.get(result.action, "")
suffix = f" — {detail}" if detail else ""
print(f" {_tick(ok)} {integ.id} ({result.action}){suffix} → {result.path}")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 "unchanged" displays identically to "not supported"

When an integration is already correctly configured, result.action is "unchanged" and ok evaluates to False, so _tick(ok) returns the same dim that is used for "not supported" integrations. A user running semble install on an already-configured machine would see a screen of dim dashes and might reasonably conclude the installation failed when every integration was already in place. Including "unchanged" in the ok set — or using a distinct tick symbol — would make the output less ambiguous.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread src/semble/installer.py
Comment on lines +522 to +525
existed = dest.exists()
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(files("semble").joinpath(f"agents/{agent.id}.md").read_text(encoding="utf-8"), encoding="utf-8")
return WriteResult(dest, "updated" if existed else "created")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _apply_subagent propagates exceptions instead of returning an error action

Every other integration helper (_apply_mcp, _apply_instructions) surfaces failures through the Action return value (e.g. "error", "skipped"), keeping _apply in control and printing a friendly message. _apply_subagent, however, has no try/except around the package-data file read. If the template file is absent from the installed package (e.g. a new agent ID was added to AGENTS without a matching .md resource), a raw FileNotFoundError propagates all the way through _apply and crashes semble install with a traceback. Wrapping the write in a try/except and returning WriteResult(dest, "error") keeps the error-reporting consistent with the rest of the function.

Suggested change
existed = dest.exists()
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(files("semble").joinpath(f"agents/{agent.id}.md").read_text(encoding="utf-8"), encoding="utf-8")
return WriteResult(dest, "updated" if existed else "created")
existed = dest.exists()
dest.parent.mkdir(parents=True, exist_ok=True)
try:
dest.write_text(files("semble").joinpath(f"agents/{agent.id}.md").read_text(encoding="utf-8"), encoding="utf-8")
except Exception:
return WriteResult(dest, "error")
return WriteResult(dest, "updated" if existed else "created")

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 2, 2026

Confidence Score: 3/5

The install path works correctly for fresh configs; the uninstall path can corrupt pre-existing configs where semble was not the first entry.

The _delete_member function only removes an adjacent comma correctly when semble was inserted as the first member. If semble was added last by another tool (e.g. claude mcp add) and the user then runs semble uninstall, the preceding entry's trailing comma is left in place, making strict-JSON files like .claude.json invalid. The JSON5-based validation guard does not catch it.

src/semble/installer.py — specifically the _delete_member function and the missing test case in tests/test_installer.py.

Reviews (2): Last reviewed commit: "Group types/vars" | Re-trigger Greptile

Comment thread src/semble/installer.py
Comment on lines +299 to +317
def _delete_member(src: bytes, member: Node) -> bytes:
"""Remove `member` plus one adjacent comma and its leading line indentation."""
start, end = member.start_byte, member.end_byte
after = end
while after < len(src) and src[after : after + 1] in (b" ", b"\t"):
after += 1
if after < len(src) and src[after : after + 1] == b",": # prefer a trailing comma
end = after + 1
else:
before = start
while before > 0 and src[before - 1 : before] in (b" ", b"\t"):
before -= 1
if before > 0 and src[before - 1 : before] == b",":
start = before - 1
while start > 0 and src[start - 1 : start] in (b" ", b"\t"):
start -= 1
if start > 0 and src[start - 1 : start] == b"\n":
start -= 1 # drop the now-empty line
return src[:start] + src[end:]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Trailing comma left when removing a non-first entry

_delete_member has two strategies for consuming the comma adjacent to a removed member: (1) look for a trailing comma directly after the member, or (2) walk backwards through leading whitespace and check the one byte that precedes it. Strategy (2) stops at the \n that ends the previous line, so when the layout is "other": {...},\n "semble": {...}, the check at src[before - 1 : before] sees \n rather than ,. The comma on the previous line is never consumed.

Concretely: if a user previously added semble via claude mcp add semble -s user -- … (which appends at the end of mcpServers) and then runs semble uninstall, the resulting .claude.json will contain a trailing comma after the predecessor entry—making the file invalid strict JSON. The _reparse_ok guard does not catch this because the JSON5 parser accepts trailing commas.

The _insert_first_member insertion strategy means the install/uninstall round-trip is safe only when semble is the first entry. Any pre-existing semble entry added by another tool stays in its original position (last) after merge_json_member updates just the value, and the uninstall then hits this path.

Comment thread src/semble/installer.py
Comment on lines +451 to +463
def _strip_toml_section(text: str, header: str) -> str:
"""Drop the TOML table beginning at `header` (a [table] line) up to the next table or EOF."""
result, skipping = [], False
for line in text.splitlines(keepends=True):
stripped = line.strip()
if stripped == header:
skipping = True
continue
if skipping and not stripped.startswith("["):
continue
skipping = False
result.append(line)
return "".join(result)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 TOML section stripping silently removes preceding blank lines and comments

When _strip_toml_section encounters a line that does not start with [ while skipping = True, it drops it unconditionally. Any blank lines or comments sitting between the semble block and the next [table] header are removed along with the semble content. For most users Codex's config won't have such comments, but silently removing user-written content on uninstall is unexpected.

Comment thread src/semble/installer.py
Comment on lines +249 to +258
def _json5_parser() -> Parser | None:
"""Return a tree-sitter JSON5 parser, downloading the grammar if needed.

"json5" ships in tree-sitter-language-pack but isn't in its typed language list, hence the cast.
"""
try:
download(["json5"])
return get_parser(cast(SupportedLanguage, "json5"))
except Exception:
return None
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _json5_parser() calls download(["json5"]) on every invocation

_json5_parser is called twice per JSON write operation—once from _json5_object and once from _reparse_ok. Each call invokes download(["json5"]), which performs a filesystem or network check on every call. Caching the Parser instance at module level would avoid redundant work, especially for agents like Claude Code or Gemini that each trigger multiple parse-and-reparse cycles during a single semble install run.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant