Skip to content

feat(sdk-cli): dotcms agent setup — one command to connect an IDE to dotCMS - #37416

Open
fmontes wants to merge 49 commits into
mainfrom
fmontes/37390-agent-setup-impl
Open

feat(sdk-cli): dotcms agent setup — one command to connect an IDE to dotCMS#37416
fmontes wants to merge 49 commits into
mainfrom
fmontes/37390-agent-setup-impl

Conversation

@fmontes

@fmontes fmontes commented Sep 4, 2026

Copy link
Copy Markdown
Member
file-1fec1824ff29861aa0bc7dba5669a1c1

Spec-Kit PR 2 of 2 — implementation. The spec is #37392; its commits are the shared ancestor here and disappear from this diff once it merges.

Fixes #37390

What it solves

Connecting an AI coding agent to dotCMS took four manual steps: find the admin panel, mint an API token, hand-edit whichever config file your editor reads, install the skills separately. Seven editors, no two storing it the same way. The @dotcms/mcp-server README covered two of them.

npx dotcms agent setup

Three questions — instance, credentials, editors — and the agent is connected.

How it works

resolve URL → confirm it IS dotCMS → resolve credentials → mint → VERIFY
  ── nothing has touched the filesystem yet ──
→ write configs (merge, chmod 0600) → .gitignore offer → skills → launch server → summary

Four properties do most of the work:

Nothing is written until the token verifies. A rejected token leaves no file, no directory, no skills install — so a bad credential can't produce seven configs that fail confusingly later. --yes/--force cannot disable it.

The instance is validated before you're asked for anything. /api/v1/appconfiguration is fingerprinted for a real dotCMS payload; a proxy or CDN answering 200 is rejected. Typing a password against a wrong address was wasted effort.

Merge, never clobber. These files already hold other MCP servers. Only the dotcms key is touched; an unparseable file is a named error that leaves it untouched. For Codex the writer parses to validate and splices to write, so hand-written comments survive.

The run proves the agent connects. After writing, it launches the configured server and confirms it lists tools. A valid token is not proof the server starts.

Command surface

npx dotcms agent setup --url … --user … --password …      # mints a token
npx dotcms agent setup --url … --authToken …              # uses one you have

--url plus one auth mode are the only required inputs; supply both and it runs unprompted, terminal or not. Targets default to every detected editor, scope to the current folder. The two auth modes are mutually exclusive — passing both is a usage error, not a silent preference.

--agent (repeatable) · -g/--global · --skip-mcp · --skip-skills · --skip-verify · -y · --force. Exit 0 all succeeded, 1 a target or the connection check failed, 2 usage error.

Targets: Claude Code, Cursor, VS Code/Copilot, Codex, Antigravity, Devin, OpenCode.

New library: @dotcms/http

create-app and this CLI both authenticate against a dotCMS instance. http.ts sets Authorization headers and follows redirects — the exact surface axios was removed from this workspace over (its Node adapter leaked Proxy-Authorization across a redirect, #37264). Two copies of that code is where the next such fix lands in one package and not the other. That, not DRY, is the case for extracting it.

core-web/libs/http/ — internal, never published:

http.ts httpGet/httpPost over native fetch; bearer auth, AbortController timeout, HttpError carrying an HTTP status or a transport code
fetch-retry.ts describeRequestFailure() — turns ENOTFOUND into a sentence, kept diagnostic so callers own the remedy
result.ts Result<T,E>
endpoints.ts the three /api/v1 paths, so the two CLIs can't drift

Consumers: @dotcms/create-app (moved off its own copies — 8 import edits, its 132 tests pass unchanged) and dotcms. Both bundle it in at build time, so nothing new reaches npm.

It sits outside libs/sdk/ deliberately: the SDK release action publishes every direct child of that directory. private: true is intent, not the mechanism — npm only enforces private for workspace publishes — so verify-package asserts the boundary instead.

What to review for

  • shared/ vs commands/agent/ — the package is organised by command group, not technical layer, because create-app and the dotCLI port fold in as siblings later. An ESLint rule enforces one-way imports.
  • targets/registry.ts — every per-editor difference is data. Adding an eighth editor is one object literal.
  • The ordering guarantee in setup.ts, and its test. It's the thing most worth breaking on purpose to check.

Also in here

  • CI fix: the SDK publish guard was npm view "@dotcms/${dir}@${version}" — scope hardcoded, name from the directory. dotcms is unscoped, so the guard could never match: first publish succeeds, every re-run then fails the release. Now reads .name. Of eleven SDK packages, this is the only one that differs.
  • apps/mcp-server/README.md points at the command; the manual steps stay.
  • libs/sdk/cli/** and libs/http/** added to nx.json's jest include — without it a project gets no test target at all.

Testing

sdk-cli 217 · http 39 · create-app 132 — all green, lint clean, production build emits the shebang.

nx run sdk-cli:verify-package asserts eight packaging invariants against the artifact npm would upload, and runs as part of nx test. Each check corresponds to a defect that actually happened: a missing shebang, @dotcms/http surviving as an unresolvable import, declared-but-unused dependencies, an internal library drifting under libs/sdk/.

Fixes were mutation-verified rather than assumed: reverting one turns its test red. That found three holes where a test existed but could not fail.

Known limitations

  • agent status and agent remove are not in this release. Re-running setup replaces a stale entry.
  • File permissions are POSIX-only; chmod doesn't touch Windows ACLs, and the summary says so rather than implying protection.
  • A failed run can leave an orphaned token — minting precedes writing. Accepted: recovering it would mean printing it, which FR-022 forbids.
  • The server reference is unpinned (@latest). Justified deviation from ADR-0019 — @dotcms/mcp-server isn't date-lockstep, so there's no matched version to pin to.

Before merge

  • PR docs(sdk-cli): add spec for dotcms agent setup #37392 approved — this is gated on the spec, not on its merge
  • Confirm dotcms@0.0.21 download volume before first publish (381/month; ^0.0.21 pins exactly, so only bare npm i dotcms changes)
  • Manual quickstart.md run, including real editors connecting

🤖 Generated with Claude Code

fmontes and others added 30 commits September 3, 2026 17:14
Spec-Kit PR 1 of 2 for #37390 — spec only, no implementation.

Specifies a `dotcms agent setup` command that collapses the four manual
steps needed to connect an AI coding agent to dotCMS (find the admin
panel, mint a token, hand-edit an IDE config, install skills) into one
command across seven agent targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…semantics

Addresses two gaps found reviewing the spec.

Setup proved the token was valid but never that the MCP server actually
starts, so a stale package cache or unsupported runtime would produce a
green summary and a broken agent. Confirmation now runs by default
(FR-024a-e, SC-002a): it launches the server as configured, confirms it
reports its tools, and reports a non-start distinctly from a credential
failure without rolling back written configs.

Writing spans up to seven files and nothing said what happens when one
fails after others succeeded. Setup now continues, reports per-target
outcomes, and exits non-zero on any failure (FR-020a-d, SC-006a).

Also: FR-013 annotated as a structural constraint rather than a testable
requirement, FR-023a covers project scope outside version control,
concurrent writes documented as a known limitation, and SC-001/SC-002
labelled design intent rather than automated gates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records four /speckit-clarify decisions.

Folder is now the default scope, not the user account: one dotcms entry
per config file, multiple instances via different folders. Because that
makes the credential-into-a-repo path the default, FR-023 is strengthened
so assume-yes takes the safe answer on the gitignore offer rather than
skipping it.

status and remove are cut from this release; only agent setup ships. User
Story 5 withdrawn, FR-029/030/031 and SC-007/008 retired, and everything
that leaned on those commands reworded. The agent sub-command group stays
as the seam for adding them later.

The written entry references the latest published server rather than
pinning a version (FR-020e).

The instance address plus one auth mode are the only required inputs;
supply both and setup completes without prompting, terminal or not
(FR-003i-l). Targets default to every detected editor and scope to the
folder, so neither blocks a run. assume-yes and force govern confirmation
prompts only and can never suppress a prompt for a missing required input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No diagnostic mode ships — no verbose flag, no debug output, no log file.
Recorded as a deliberate decision rather than an omission, with FR-032a
requiring every failure message to be self-sufficient, since "re-run with
more detail" is not available as a remedy.

Terminology normalized to "token" for the thing minted, supplied, verified
and written; a username and password are named as such rather than called
"credentials". No behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The /speckit-plan ADR gate found that ADR-0019 (accepted) requires SDK
packages to compare the instance's dotCMS version against their own and
warn, fail-open, when the instance is older. dotcms ships from libs/sdk/,
is published by the SDK release pipeline, and is date-lockstep versioned,
so the requirement applies.

FR-005a reuses the response already fetched for the reachability check,
so it costs no additional request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 0 + Phase 1 output of /speckit-plan for #37390.

plan.md records the Constitution Check (PASS, three declared TDD
exceptions needing sign-off) and the ADR Alignment gate. ADR-0019 is the
one binding ADR and produced two conflicts: the unpinned @latest server
reference is a justified deviation recorded in Complexity Tracking, and
the missing CMS compatibility check is complied with, now FR-005a.

research.md resolves ten unknowns. Three change the plan: the nx.json
jest include entry is required rather than optional, @dotcms/mcp-server
is outside date-lockstep with no release workflow in this repo, and
chmod 0600 is a no-op on Windows so FR-021 is POSIX-only and must be
reported honestly rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first layout put targets/, verify/, skills/ and asks.ts at the top
level beside genuinely shared api/ and utils/. That reads fine at one
command and degrades at two: create-app and the dotCLI port would spray
files into the same directories with nothing marking which files serve
which command.

Restructured to shared/ plus commands/<group>/, mirroring what the user
types. A second group is a new directory and zero edits to existing
files. Adds an explicit one-way dependency rule — groups may import from
shared/, never from each other, and shared/ never imports from a group —
worth enforcing with ESLint rather than convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged main and re-checked create-app. Decision reversed: extract, not
copy.

http.ts sets Authorization headers and follows redirects -- the exact
surface axios was removed from this workspace over, after semgrep found
its Node adapter leaks Proxy-Authorization across a redirect to a
non-proxied origin. Two copies of that code is where the next such fix
lands in one package and not the other. That, not DRY, is the argument.

http.ts, fetch-retry.ts, result.ts, the URL helpers and the endpoint
constants move to a new internal lib core-web/libs/cli-shared -- no
package.json, never published, inlined into both consumers by esbuild.
Blast radius is 8 one-line import edits in create-app; its two spec files
travel with their code.

libs/cli-shared sits outside libs/sdk/ deliberately: the deploy action
treats every direct child of libs/sdk/ as a publishable @dotcms/<dirname>.

Token minting is NOT extracted -- create-app's getAuthToken returns
Result values that are pre-formatted chalk strings, so presentation is
entangled with the call. Only the endpoint constants are shared.

Extraction lands as its own commit at the head of this PR, gated on
create-app's 12 spec files passing with zero edits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
http.ts sets Authorization headers and follows redirects -- the surface
axios was removed from this workspace over, after semgrep found its Node
adapter leaks Proxy-Authorization across a redirect to a non-proxied
origin. A second copy in the new dotcms CLI is where the next fix of that
kind lands in one package and not the other. That, not DRY, is the case
for extracting.

Generated with nx g @nx/js:library. Named for what it is -- the HTTP
layer for talking to a dotCMS instance -- not for the CLIs that are its
only consumers today. It sits outside libs/sdk/ deliberately: the deploy
action treats every direct child of libs/sdk/ as a publishable
@dotcms/<dirname>. package.json is private, so publishing is refused;
consumers inline it via esbuild.

Moves http.ts, fetch-retry.ts, result.ts and their two spec files out of
create-app, adds endpoints.ts so the two CLIs cannot drift on API paths,
and repoints 8 imports.

Three things this surfaced that the plan had wrong:
- Two spec files import the moved code, so the gate is "no test logic
  changes", not "zero test-file edits".
- jest.preset.js does not map tsconfig paths, so create-app needed a
  moduleNameMapper entry or the alias compiles but never resolves.
- enforceBuildableLibDependency forbids a buildable lib importing a
  non-buildable one, so libs/http needed a tsc build target.

Verified: sdk-create-app 132 tests/10 suites, http 34 tests/2 suites,
both builds succeed, lint clean, @dotcms/http absent from the bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the Nx generator stub. Covers the four modules, the security
reason the code is shared rather than copied, and the two wiring steps a
consumer must not miss -- the jest moduleNameMapper entry and the
buildable-lib constraint -- both of which fail confusingly when skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removing the throwaway probe alias left a trailing comma after the
@dotcms/http entry, making the file invalid JSON. TypeScript and esbuild
parse tsconfig leniently, so every build and test still passed and the
breakage was invisible -- a strict JSON reader would have failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Minting happens before configurations are written, so a run that mints
and then fails leaves a real 365-day token on the instance that was never
displayed and never recorded. Re-running mints another, and nothing
identifies or revokes them.

Accepted rather than mitigated. The alternative that actually recovers
the credential is printing it, which contradicts FR-022 -- a rule User
Story 3 rates P1. Orphans expire within a year and the failure requires
an already-broken environment.

Recorded in Assumptions rather than left silent, so a reviewer sees the
trade-off instead of rediscovering it later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1 and 2 of tasks.md -- scaffolding and types only. No behaviour,
so nothing here precedes a TDD gate.

Generated with nx g @nx/js:library, then given create-app's proven
esbuild config: esm, bundled, runtime deps external, and the
#!/usr/bin/env node banner that exists only in the production
configuration.

The generator named the project "cli". Renamed to "sdk-cli": the SDK
release action builds --projects='sdk-*', so a project named "cli" would
never be built or published.

jest.config.ts carries the moduleNameMapper for @dotcms/http -- this
workspace does not map tsconfig paths in jest.preset.js, so without it
every suite touching the alias fails on module resolution instead of on
the behaviour under test, and the Red gate could not be trusted.

eslint.config.mjs enforces the one-way dependency rule: command groups
may import shared/, shared/ may not import a command group, and groups
may not import each other.

Verified: production build emits the shebang, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes the User Story 1 tests: URL resolution and reachability, the
ADR-0019 compatibility warning, token minting and verification, target
registry and per-platform paths, fresh-file JSON writing, the ordering
guarantee, the connection check, skills delegation, and the summary.

Signature-only stubs accompany them so the failures are assertion
failures rather than module-resolution errors -- T024 rejects the latter
as an invalid Red.

Running Red before requesting approval surfaced four vacuous tests that
passed against a throwing stub, because rejects.toThrow() and
not.toThrow(/../) are satisfied by any error including "not implemented".
They asserted nothing and could never have gone Red. Tightened to assert
positively: the message must name the address, the token rejection, or
the conflicting options.

Red: 50 failed / 0 passed across 8 suites, 0 module-resolution errors.

HALTING at T023 -- developer approval of the test set, including explicit
sign-off on the three test types plan.md declares cannot be implemented.
No implementation code written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
connect.spec.ts: the child/spawn mock was rebuilt in all five it blocks.
Hoisted to beforeEach.

setup.spec.ts: process.chdir() was wrong, and not only stylistically.
chdir is process-global and Jest reuses a worker across spec files, so
one suite was moving the working directory out from under the others --
the run reported 50 tests where 55 exist. Five tests were never
executing.

Replaced with an injected working directory: RunOptions.cwd and
configPath(scope, cwd). That removes the global mutation and improves the
production design, since folder-scope resolution is now a parameter
rather than ambient state.

Kept real temp directories rather than switching to a mocked filesystem.
"Nothing was written" is the assertion the ordering suite turns on, and
only a real empty directory settles it; a mocked fs would prove one API
was not called, which a write through any other path slips past.
plan.md's Test Strategy said "mocked filesystem" and was wrong -- corrected.

Red now: 55 failed / 0 passed across 8 suites, 0 module-resolution
errors, 0 vacuous passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Approval (T023) and Red (T024) recorded, then implementation to Green.

Adds the shared layer -- named errors, env reading, redaction, instance
resolution with the ADR-0019 compatibility warning, token mint/verify
over @dotcms/http, and generic merge/write/chmod -- plus the agent group:
the seven-target registry, the JSON writer, the connection check, skills
delegation, the summary, and the commander wiring.

The ordering guarantee is the load-bearing part: nothing touches the
filesystem until the token has been verified, and --yes/--force cannot
disable that.

Two mechanical fixes to the approved tests, changing no assertion. Under
ts-jest the node:os and node:child_process namespace objects are
non-configurable, so jest.spyOn on them throws "Cannot redefine
property"; replaced with module-factory mocks. And the summary now writes
via process.stdout rather than console.log, which the workspace lint rule
reserves for warn/error.

Verified: 55/55 tests, 8 suites; lint clean; production build emits the
shebang with @dotcms/http inlined. Smoke-tested the real binary --
conflicting auth and unknown target exit 2, unreachable exits 1, and no
file is written on any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds 22 tests for User Story 2: JSON merge preserving other servers and
unrelated settings verbatim, a similarly-named sibling left alone,
replace-not-duplicate, malformed input as a named error with the file
untouched, the Codex TOML target round-tripping comments and unrelated
tables, overwrite confirmation, and partial-failure semantics.

Installs smol-toml (1.8.0) and stubs the TOML target.

Only 9 of the 22 are Red. The other 13 passed on arrival because US1's
implementation already covered them -- config-file.ts was written with
merge semantics and setup.ts with continue-on-error, both nominally US2
scope. Those are regression locks, not Red->Green tests, and the gate
note in tasks.md says so rather than presenting a clean Red that is not
there.

RunOptions gains confirmOverwrite, injected so the FR-017 confirmation is
testable without a terminal and setup.ts stays free of prompt mechanics.

77 tests total: 68 passing, 9 failing, 0 module-resolution errors.

HALTING at T044.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ran targeted mutants against every behaviour that was implemented before
its tests. Four were caught. Two survived with zero extra failures:
disabling chmod, and making redact() return the raw secret. Neither was
green-on-arrival -- both were entirely untested, and both are security
requirements (FR-021/SC-004, FR-022/SC-005).

Adds config-file.spec.ts and redact.spec.ts. The permission assertions are
POSIX-guarded; the honesty assertion is not, and checks that
permissionsApplied tracks the platform capability rather than being
hard-coded true.

Re-ran both mutants against the new tests: permissions now +3 failures,
redaction +2, where both previously survived at +0.

85 tests: 76 passing, 9 failing (the genuine Phase 4 Red).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the Codex writer with smol-toml, round-tripping comments and
unrelated tables rather than regenerating the file -- replacing an
existing [mcp_servers.dotcms] in place requires knowing where a table
begins and ends, which is parsing by another name.

Adds hasEntry() as a read-only check so FR-017's confirmation happens
before anything is modified rather than as a rollback, and restrictFile()
so the JSON and TOML writers share one permission path instead of
drifting.

setup.ts selects the writer from the registry's `format` field, so the
flow still branches on nothing target-specific (FR-013).

Mutation-checked the new code: dropping other servers and unrelated
tables from the TOML writer takes failures 0 -> 3.

85/85 passing, lint clean, production build succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folder scope is the default, so a token lands in a working directory on
nearly every run. protectFromVersionControl() names every file it went
into, offers exclusion, and warns where it cannot help.

Three behaviours the tests pin down:
- Not a git repository: the files are still named and warned as
  unprotected, rather than the step being silently skipped (FR-023a).
  Silence is how a token reaches a public repo.
- A repo-root .mcp.json is conventionally COMMITTED, so excluding it is
  the unusual choice -- warned explicitly rather than quietly gitignored
  (FR-024). The one place the safe default is wrong for the workflow.
- --yes supplies the SAFE answer (exclude) rather than bypassing the
  prompt, inverting the conventional meaning of -y (FR-023).

Red first: all 9 new tests failed, 0 resolution errors. Then
mutation-verified -- dropping the no-repo warning, the
normally-committed warning, the decline check, or the dedup each takes
exactly one test red.

94/94 passing, lint clean, production build succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…060-T072)

Red first: 11 new tests, all failing, 0 resolution errors.

Two rules that are easy to implement conventionally and wrongly, so both
are pinned by tests and mutation-checked:

- Prompting is driven by a MISSING REQUIRED INPUT, not by a mode. Supply
  the url and one auth mode and nothing is asked, terminal or not. A run
  does not become interactive merely because a terminal exists.
- --yes governs CONFIRMATIONS ONLY. The usual reading, "assume defaults
  for everything", would silently skip a required input. Mutating
  resolveRequiredInputs to honour --yes that way takes one test red.

shared/prompts.ts owns the rules; commands/agent/prompts.ts owns how to
ask, behind a PromptPort. That split is what lets the rules be tested
without a terminal.

Uses inquirer's own prompt module rather than the five @inquirer/*
sub-packages -- inquirer is already a declared dependency here and in
create-app, and the sub-packages would add install weight for every npx
user to no benefit.

Fixed an unrealistic mock found during Red: the url prompt returned
'typed-url', which correctly failed validation, so the test died before
its assertion.

105/105 passing, lint clean, production build succeeds. Binary verified
on four non-interactive failure paths: all exit 2 in under 120ms with no
file written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mutation-tested every behaviour implemented before its tests. Six mutants
were caught; two survived.

Hole 1 -- an assertion that could only fail on a platform we never run.
expect(permissionsApplied).toBe(CAN_RESTRICT) is true === true on POSIX,
so a hard-coded true satisfied it; the assertion could only bite on
Windows. Made the capability injectable (writeMerged({ canRestrict })) so
the claim is checked against a platform that cannot restrict, from any
platform.

Hole 2 -- nothing covered FR-003j/FR-010: omitting --agent must configure
every detected editor. Added three tests (detected set is used, empty
detection writes nothing and exits 0, default scope is the folder).

Both mutants now take a test red.

Also recorded a method note: hard-coding a literal scope produced a
COMPILE error, not a failing test -- TypeScript narrowed the type and
rejected the later comparison as provably false. The tell for an invalid
mutant is the collected test TOTAL changing, not the failure count. A
sweep must compare totals.

109/109 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… hole

Fourteen more mutants across instance, auth, constants, json-target,
connect, skills and ui. Thirteen caught, one survived.

Removing the ^https?:// guard failed nothing: new URL() still rejects a
bare host, so the existing test passed through that fallback. But
new URL() parses ftp://, file:// and javascript: without complaint, so
those would have reached fetch and been written into an editor's config
with no test objecting. Added four tests -- three bad schemes plus plain
http://localhost:8082, since local instances are the common case and must
keep working. The mutant now takes 3 tests red.

Also redid the compat-warning mutant in a valid form; the first attempt
broke compilation.

Everything else held, including the ones that are requirements rather
than details: token in argv instead of the child environment, child not
killed, 404 misclassified as a plain exit, skills invoked per-target,
skills failure reported as success, summary saying "ready" after a failed
connection, and unverified skills shown as installed.

113/113 passing, lint clean, production build succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
T081 error-message audit, written as a test rather than a one-off check.
FR-032a makes "something failed" a defect, since there is no verbose mode
to fall back on. Found two raw Error throws that named the target but
offered no action -- replaced with NoConfigPathError. InvalidUrlError
described the problem without the fix; now imperative. errors.spec.ts
asserts every error names its subject, carries an action, is a sentence,
leaks no secret, and is classified correctly for exit 1 vs 2.

T082 proved no raw fetch error escapes: 12 cases across all three network
call sites.

T083 surfaced that ora and chalk were declared dependencies and externals
that nothing imported -- every npx user installing them for nothing. That
was a symptom: the connection check spawns npx and can run a minute on a
cold cache with the CLI printing nothing. Added an onProgress port
(mirroring promptPort) so setup.ts reports WHAT is happening and the
command layer decides HOW -- a spinner on a terminal, plain lines in CI.
Both dependencies now earn their place.

Extended describeRequestFailure in @dotcms/http for ENOTFOUND, ECONNRESET
and the TLS cases; "ENOTFOUND" is not a sentence. Kept them DIAGNOSTIC:
the first attempt baked in advice and produced "Host not found - check
the address... Check the address and that the instance is running." The
caller owns the remedy, and a test now enforces that.

T085 points the mcp-server README at the command while keeping the manual
steps.

Needed create-app's transformIgnorePatterns: [] -- chalk 5 and ora are
ESM-only and Jest skips node_modules by default.

sdk-cli 163, http 39, create-app 132. Lint clean, build succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cope

The publish step's idempotency guard was
`npm view "@dotcms/${sdk}@${VERSION}"`, deriving the package name from the
directory and hardcoding the scope. That held while every SDK was scoped.
It breaks on `dotcms`, the unscoped CLI: the guard could never match, so
the first publish succeeded and every re-run then tried to publish a
version that already existed and failed the release step -- the exact
stall the guard exists to prevent.

Reads `.name` from each package.json instead, which works for any naming
scheme. Same fix in the version-rewrite step, where sibling dependencies
were repointed under an assumed scope.

Considered replacing the loop with `nx release publish`, which knows each
project's real name. Rejected: it has no already-published semantics, so
it would 403 on a re-run and reintroduce the stall this guard prevents.

Left the examples/* loop alone -- examples import SDK libraries, never the
CLI binary, so the scoped assumption still holds there.

Verified: all five run blocks pass `bash -n`; simulated against a fake
dist containing both a scoped and an unscoped package, where the old
guard looked up a nonexistent @dotcms/cli and the new one finds dotcms;
and confirmed via `nx show projects --projects='sdk-*'` that sdk-cli is in
the set this action builds, with cli the only one of eleven whose npm
name differs from @dotcms/<dir>.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mirrors create-app's verify-package pattern: nx:run-commands running a
bash script, dependsOn build, wired into test so it cannot be forgotten.

Asserts what unit tests structurally cannot see, since they run against
the source tree and this runs against the artifact npm would upload. Each
check is a defect that actually happened this session:

  - shebang present, so `npx dotcms` has an interpreter
  - bin points at a file that exists
  - @dotcms/http is INLINED -- it is unpublished, so a surviving import
    would be unresolvable for every user
  - every declared dependency is actually imported (ora and chalk were
    declared, externalised and unused: install weight for nothing)
  - npm pack includes index.js and README.md
  - the package name is still the unscoped `dotcms` the release guard
    resolves by reading .name

Verified the verifier: declaring an unused dependency, renaming the
package, stripping the shebang, and leaving @dotcms/http as an import
each turn it red.

Also corrected research.md R1. The shebang trap is narrower than
documented -- defaultConfiguration is production, so a bare `nx build`
does carry it. The trap needs an explicit non-production configuration,
which is why the check asserts the artifact rather than the command.

Final gate: sdk-cli 163, http 39, create-app 132; lint clean on all three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I stated in a commit message and in the README that @dotcms/http cannot
be published because package.json is private. That is wrong. npm 10's
check is:

    if (workspace && manifest.private) throw EPRIVATE

It is gated on `workspace`. It stops `npm publish -w <pkg>`; it does not
stop a direct `cd libs/http && npm publish`, which exits 0 and packs the
tarball. Confirmed against npm's source and by dry-run.

What actually keeps the library unpublished is its LOCATION: the SDK
release action iterates the direct children of core-web/libs/sdk/, and
this sits outside that directory with no nx-release-publish target.
private: true stays as a declaration of intent, but it is not the
mechanism, and saying otherwise was a false assurance.

Since the real guarantee is a directory boundary rather than a flag,
verify-package.sh now asserts it: no package marked private may sit under
libs/sdk/. Planting one there turns the check red -- verified. That moves
the invariant out of my head and into the build.

README corrected. Recorded as R17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fmontes and others added 6 commits September 4, 2026 12:58
Two changes, both from running the CLI by hand.

Order. resolveRequiredInputs asked for the address AND the credential in
one pass, then checkReachable ran afterwards -- so a developer typed a
username and password against an address that was never going to work,
and learned it only after the effort. Split resolveInstanceUrl out: the
address is resolved and checked against the live instance first, and
nothing asks for a credential until the instance is confirmed.

Message. "answered (HTTP 404) but is not a dotCMS instance --
/api/v1/appconfiguration did not return a dotCMS configuration. Check the
address points at the dotCMS server itself, not a proxy, CDN or site
root." explained our method to someone who needs a verdict. Now:
"https://example.com is not a valid dotCMS instance. Check the address."
A test pins that: the message must not mention the endpoint, the payload
shape, proxies or CDNs, and must stay under 120 characters.

Three tests cover the ordering directly -- no password prompt when the
address is not dotCMS, none when it is unreachable, and the address is
always asked for first.

193 tests, lint clean. Verified by hand: --user and --password supplied
against example.com never reach authentication, exit 1, nothing written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1. --skip-mcp silently killed the skills install and the summary. The
   early return skipped everything downstream, so --skip-mcp alone did
   nothing and said nothing. It now skips WRITING only: skills are chosen
   from the selected targets rather than from successful writes, each
   target is reported as skipped, and the connection check is skipped
   implicitly since there is no configuration to prove.

2. "ftp://x" suggested "https://ftp://x" -- the suggestion stripped only
   https?://. Now strips any scheme.

3. Every failed target claimed a permissions problem on macOS.
   permissionsApplied is false for a failed write because nothing was
   written, not because the platform refused. Gated on the outcome.

4. TOML comments were dropped. smol-toml does not round-trip them, and
   Codex's config.toml is hand-maintained. The writer now parses to
   VALIDATE and splices to WRITE: only our own tables are replaced
   textually, everything else survives byte-for-byte. A comment
   immediately before the next table belongs to that table -- the first
   attempt ate it, and there is a test for exactly that.

5. Pre-existing directories were re-chmodded to 0700, widening a .cursor
   the developer had set to 0500. mkdir(recursive) reports what it
   created; only that is restricted now.

6. Env-only auth conflict named flags the developer never typed. The
   error now names the source actually used.

7. Duplicate --agent inflated the count. Dedup now happens before the
   count, not inside the write loop.

Fixing 1 exposed an eighth: with writing skipped, the version-control
step still announced "these files now contain an access token" about
files never created. Narrowed to written/replaced.

Five of the eight are one shape -- a value computed for one purpose
reused where its meaning differs. Recorded as R22.

214 tests. Each fix mutation-checked: reverting it turns its test red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tead

The comment-preservation rewrite hand-emitted command, args and env
behind a cast:

    buildEntry(...) as { command: string; args: string[]; env: ... }

That cast asserts the entry has exactly those keys, so omitting `type`
was not an error -- the compiler had been told the field does not exist.
Every JSON target still wrote type: "stdio"; codex alone stopped. Any
field added to buildEntry later would have vanished the same way, with no
compile error and no failing test.

Fixed structurally rather than by adding the field back. renderEntry now
serializes whatever buildEntry returns via smol-toml's stringify.
Comments are irrelevant for that block because it is generated; the
splice still protects everything outside it, so the file keeps its
annotations and the entry keeps every field.

The guard is a structural assertion, not a field list:

    expect(doc.mcp_servers.dotcms).toEqual(buildEntry(codex(), URL, TOKEN))

so a new field is covered the moment it exists. Reverting to the
hand-emitted version turns three tests red -- verified.

Second time today a cast hid a defect the type system had been tracking
(R20 was re-deriving an object from an id). Recorded as R23: where a value
must mirror another, compare it to that value, not to a description of it.

217 tests, lint clean. Verified on disk: comments preserved AND
type = "stdio" present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng or missing

FR-003b said the conflict error must name the conflicting "options". The
conflict can come from environment variables, and reporting --authToken
to someone who set DOTCMS_AUTH_TOKEN sends them hunting for a flag they
never typed. Now: name the inputs actually used.

FR-003c1 is new. Nothing said the skip options are independent, and the
implementation read --skip-mcp as "skip everything downstream", so it
silently installed no skills and printed no summary. The only permitted
implication is FR-024b's: with nothing written there is no configuration
for the connection check to prove.

FR-016a is new. "Preserves everything else exactly" was read as data
only, so the TOML writer re-serialized a parsed document and deleted the
developer's comments. For a hand-maintained format that is data loss even
though every value survives.

Not changed, because the spec was already right and the code was not:
FR-021 says "any directory setup CREATES" (the implementation re-chmodded
pre-existing ones), FR-023 says "every file it PLACED A TOKEN INTO" (it
named files that were only skipped), and FR-024b already covered the
connection check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fmontes
fmontes requested a review from a team as a code owner September 4, 2026 20:47
@github-actions github-actions Bot added Area : CI/CD PR changes GitHub Actions/workflows Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries labels Sep 4, 2026
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fmontes's task in 2m 5s —— View job


Code Review — dotcms agent setup

Reviewed the diff against origin/main, focusing on the ordering guarantee, the writers, the connection check, and the prior review findings.

New Issues

  • 🟠 High: core-web/libs/sdk/cli/src/commands/agent/connect.ts:122The MCP handshake omits notifications/initialized, so the connection check likely times out on every real run. After initialize (id 1) the code writes tools/list (id 2) directly, with no notifications/initialized in between. Per the MCP lifecycle a client must send that notification before any non-initialize/ping request; the MCP SDK server rejects earlier requests with -32002 Server not initialized. That error response carries no result.tools, so confirmConnection never resolves ok and instead waits out the 60s timer → connection: 'failed'exitCode 1 (FR-024a) on an otherwise successful setup. This repo's own server proves the required order — apps/mcp-server/src/smoke/server-boot.spec.ts:104-112 sends initialize → notifications/initialized → tools/list. This was raised in the prior review and acknowledged as valid but deferred; it is still open and it degrades the headline "prove the agent connects" feature. Fix:

    child.stdin?.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`);
    child.stdin?.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' })}\n`);

    Fix this →

  • 🟡 Medium: core-web/libs/sdk/cli/src/commands/agent/connect.spec.ts:25No test exercises a successful handshake, which is why the bug above ships green. Every case drives a timeout, an env assertion, or a fetch-failed exit; none writes a real initialize reply + tools/list result through child.stdout and asserts { ok: true, toolCount }. A test that feeds those two frames would have failed on the missing notification (and would fail today), and would also lock in the handshake ordering that the PR body calls "the thing most worth breaking on purpose to check."

Resolved

  • core-web/libs/sdk/cli/src/commands/agent/setup.ts:113-123 — FR-005a / ADR-0019 warning is now wired: checkReachable's InstanceInfo is kept, compared via compatibilityWarning(instance.version, TOOL_VERSION), and surfaced through onWarning + warnings[]. Fail-open preserved.
  • core-web/libs/sdk/cli/src/commands/agent/setup.ts:162-168 — Auth retry no longer re-reads the rejected env/flag credential; it calls promptForAuth, which consults neither options nor env, so a rejected DOTCMS_PASSWORD/DOTCMS_AUTH_TOKEN no longer silently exhausts all three attempts. Done without mutating process.env, which is the better choice.
  • core-web/libs/sdk/cli/src/commands/agent/connect.ts:20 — dead code === null ? 'exited' : 'exited' ternary replaced with return 'exited'; classify no longer takes the unused code.

Everything else I checked holds up: the write-after-verify ordering (setup.ts:152 guard comment and control flow), the TOML splice preserving comments (toml-target.ts:44-70), the JSON merge-not-clobber via offset splicing (config-file.ts:139-170), the permissionsApplied honesty (restrictFile return, not CAN_RESTRICT), the token-via-env-never-argv path, and the CI guard now reading .name from package.json (action.yml:188,196) rather than a hardcoded scope.

The High finding is worth fixing before merge — as written, agent setup will very likely report a failed connection and exit 1 even when configs were written correctly. If server behavior differs from the SDK default and tools/list is answered pre-init, please confirm that against the actual @dotcms/mcp-server@latest and add the success-path test either way.
· fmontes/37390-agent-setup-impl

@fmontes fmontes added the PR : dotbot review Trigger dotbot AI code review on this PR label Sep 4, 2026
// real dotCMS is anyone asked for a credential: a password typed against a wrong
// address is wasted effort, and the failure would land after the work rather than
// before it.
const url = await resolveInstanceUrl(opts, opts.promptPort);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [P1] setup.ts:95 surface compatibility warning from checkReachable

Current code:

const url = await resolveInstanceUrl(opts, opts.promptPort);
step(`Checking ${url}`);
await checkReachable(url);

Problem: Discards InstanceInfo.version; compatibilityWarning() has no production caller.

Fix:

const info = await checkReachable(url);
const warning = compatibilityWarning(info.version, toolVersion);
if (warning) step(warning);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c3c2a1c. checkReachable's result is now kept and compared against TOOL_VERSION, surfaced via onWarning (live) and warnings[] (summary). TOOL_VERSION reads package.json at build time — the release pipeline rewrites .version before building, so a literal would report 0.2.0 from a package published as 26.9.x.


opts.onAuthRetry?.((error as Error).message, attempt, MAX_AUTH_ATTEMPTS);
// Ask again from scratch: the url is settled, the credential is what was wrong.
inputs = await resolveRequiredInputs({ url, cwd: opts.cwd }, opts.promptPort);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 [P2] setup.ts:135 re-prompt fresh credential on auth retry

Current code:

inputs = await resolveRequiredInputs({ url, cwd: opts.cwd }, opts.promptPort);

Problem: Retry re-reads rejected env credential, exhausting attempts without fresh prompt.

Fix:

delete process.env[ENV_KEYS.authToken];
delete process.env[ENV_KEYS.password];
inputs = await resolveRequiredInputs({ url, cwd: opts.cwd }, opts.promptPort);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c3c2a1c, though not with delete process.env[...] — mutating the environment would also affect anything else reading it. Instead extracted promptForAuth(port), which consults neither options nor env, and the retry calls that. For a credential the instance just rejected, the only useful source is the human.

method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'dotcms', version: '0' } }
})}\n`
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [P1] connect.ts:109 MCP handshake omits notifications/initialized before tools/list

Current code:

        );
        child.stdin?.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' })}\n`);

Problem: Missing notifications/initialized notification; tools/list may never be answered.

Fix:

        );
        child.stdin?.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`);
        child.stdin?.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' })}\n`);

The MCP protocol requires the initialized notification before non-initialize requests; the server's own smoke test (apps/mcp-server/src/smoke/server-boot.spec.ts:104-112) sends initialize → notifications/initialized → tools/list. Without it the 60s timer can fire on every otherwise-successful setup, reporting connection: 'failed' and exit code 1 (FR-024a).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid, but deliberately not in this commit — scoped to the two findings above. Tracking as follow-up.

// before it.
const url = await resolveInstanceUrl(opts, opts.promptPort);
step(`Checking ${url}`);
await checkReachable(url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 [P1] setup.ts:95 discarded checkReachable result leaves FR-005a warning unimplemented

Current code:

    const url = await resolveInstanceUrl(opts, opts.promptPort);
    step(`Checking ${url}`);
    await checkReachable(url);

Problem: Return value discarded; compatibilityWarning() has no production caller.

Fix:

    const info = await checkReachable(url);
    const warning = compatibilityWarning(info.version, toolVersion);
    if (warning) step(warning);

checkReachable returns InstanceInfo { version } (instance.ts:12,71) and compatibilityWarning (instance.ts:117) is exported and unit-tested for the ADR-0019 / FR-005a warning, but grep finds no non-test caller — the version plumbing is dead code and the developer is never told the instance is older than the tool.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Duplicate of the finding above — fixed in c3c2a1c.

if (/Unsupported engine|requires Node|SyntaxError|Unexpected token/i.test(stderr)) {
return 'runtime-unsupported';
}
return code === null ? 'exited' : 'exited';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 [P2] connect.ts:20 dead ternary returns 'exited' on both branches

Current code:

    return code === null ? 'exited' : 'exited';

Problem: Both branches return the same value; signal-killed case is unlabeled.

Fix:

    return 'exited';

Or give the code === null (killed-by-signal) case a distinct label per FR-024c's requirement to name distinguishable failure causes. No functional bug, but the branch is clearly unfinished.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid, but deliberately not in this commit — scoped to the two findings above. Tracking as follow-up.

…-read env

**1. The compatibility warning was dead code.** `compatibilityWarning()` existed and was
unit-tested, `readVersion()` existed and was unit-tested, and nothing called one with the
other. A developer on a dotCMS older than the tool was never told. Same shape as the
`entity.version` defect earlier in this branch: the parts were tested, the join was not.

`setup.ts` now keeps the `checkReachable` result and compares it against `TOOL_VERSION`,
surfacing the notice through `onWarning` and the run's `warnings[]`. Both ends are wired:
`agent/index.ts` prints it live, `ui.ts` repeats it in the summary. Writing the producer
without the consumer is exactly the bug being fixed here, so the summary render is tested
too.

`TOOL_VERSION` reads `package.json` at build time rather than a literal — the SDK release
pipeline rewrites `.version` to the release tag before building, so a hardcoded value would
report `0.2.0` from a package published as `26.9.x`. The import is *named*, not default:
a default import makes esbuild inline the entire manifest (dependency list, publishConfig)
into the shipped bundle.

**2. An auth retry re-read the credential the instance had just rejected.** The retry called
`resolveRequiredInputs`, which consults options and the environment before prompting. So a
token from `DOTCMS_AUTH_TOKEN` — or a password from `DOTCMS_PASSWORD` — was re-read unchanged
and re-submitted until the three attempts ran out, with no prompt ever shown. For env users
the retry feature did nothing at all.

Extracted `promptForAuth(port)`, which consults neither options nor the environment, and the
retry path calls that. For a credential the instance has just refused, the only useful source
is the human.

Version set to 0.2.0.

Both fixes mutation-verified: reverting either turns its own tests red and no others, with the
collected total unchanged. 225 tests, lint clean, verify-package green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`nx format:check` (Maven `format-test`) failed the Frontend Unit Tests job on 26 files across
this branch. Whitespace only, no behaviour change: prettier collapses single-element JSON
arrays onto one line and rewraps a number of long expressions.

The pre-commit hook runs `nx format:write` against *staged* files only, so a file formatted at
commit time can still drift when a later prettier-relevant edit lands elsewhere — the gate is
repo-wide. Verified with a full `format:check`, then re-ran the three affected suites:
sdk-cli 225, http 39, sdk-create-app 132.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fmontes and others added 3 commits September 7, 2026 08:02
…ing it

Asked what happens to a folder that already holds other MCP servers, I ran it rather than
re-reading the spec: `.cursor/mcp.json` seeded with `github` and `postgres`, `.vscode/mcp.json`
with a comment, `.codex/config.toml` with hand-written annotations.

No server was lost — but two real problems surfaced.

**JSON files were reformatted.** `JSON.stringify(next, null, 2)` rebuilds the document from its
parse tree, so values survived and bytes did not: inline arrays exploded, unrelated settings
re-indented, the whole file churned. FR-016 and target-configs.md both promise the untouched
keys come through byte-for-byte, and on a shared `.mcp.json` this is the difference between a
one-entry diff and a whole-file one.

**A valid VS Code config was rejected outright.** `.vscode/mcp.json` is JSONC — the same family
as `settings.json` and `launch.json` — so a `// comment` made `JSON.parse` throw and the target
failed with "is not valid JSON". Nothing was destroyed and the exit code was right, but we told
a developer their working config was broken and declined to configure the editor. Trailing
commas failed identically.

Both have one cause and one fix: do for JSON what `toml-target.ts` already does — parse to
validate, splice to write. Parsing is now `jsonc-parser`, so comments and trailing commas are
input rather than corruption while a genuine syntax error is still MalformedConfigError
(FR-018).

`jsonc-parser`'s own `modify` was the obvious tool and the wrong one: with `formattingOptions`
it reflows the sibling its insertion point abuts, without them it emits the entry compacted onto
one line. Both rewrite bytes we were asked to leave alone. `setProperty` computes the one offset
itself, and a new entry adopts the indentation already in the file rather than imposing two
spaces.

The diff a developer now sees is our entry plus the trailing comma syntax requires. Nothing else.

Mutation-verified, three mutants, collected total unchanged at 232: strict `JSON.parse` kills the
comment and trailing-comma tests; a fixed two-space indent kills the indentation test; rebuilding
the document kills four, including the byte-for-byte one. That last test only bites because it
pins a neighbouring line verbatim — an earlier version asserted values and passed against the
reflow it was written to catch.

Contracts updated to describe what the code does. 232 tests, lint clean, verify-package 8/8,
format:check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… charge

Four parallel cleanup reviews (reuse, simplification, efficiency, altitude). Everything below
was flagged by at least two of them independently; no behaviour changes except where noted.

**One writer contract keyed by format.** `writeTomlTarget` returned a bare `string` while the
JSON writer returned `WriteResult`, so `setup.ts` hand-assembled the missing half — including
`permissionsApplied: CAN_RESTRICT`, a platform constant standing in for an observation the TOML
writer had already made and thrown away. For Codex the summary asserted a protection nobody
checked. `config-file.spec.ts` even documents that comparing against `CAN_RESTRICT` is the
tautology the injected flag exists to avoid; the flow had hardcoded it.

Both writers now satisfy `TargetWriter` and `WRITERS[target.format]` selects one, so `format`
joins `containerKey`/`entryShape` as registry data the flow never inspects. That removes the
`isToml` branch that sat three lines under a comment claiming the flow branches on nothing
target-specific, and the `smol-toml` import from the flow module. An eighth editor in a third
format is now a registry entry plus a writer, not a third arm here.

Behaviour change, in the honest direction: Codex's `permissionsApplied` is the real chmod result.

**`shared/` no longer knows a second format exists.** `hasEntry` took an injected `parse` for
exactly one caller — the mechanism by which TOML knowledge reached `shared/config-file.ts`, and
which left TOML with two disagreeing notions of "is our entry present": object-lookup for the
overwrite prompt, line-span for the write. They agreed by luck. Each writer answers for itself
now, `hasTomlEntry` backed by the same `findEntrySpan` the write uses.

**`.mcp.json` moved into the registry.** It was a basename set inside `gitignore.ts`, a module
whose whole point is not knowing editors exist. It is `folderConfigIsCommitted` on the target
now. The old unit test only proved a path matched a string; the binding worth pinning is
target -> warning, so that is asserted end to end (mutation-verified: dropping the field turns
it red).

**Dead code, verified before deleting.** `instance.ts`'s `resolveUrl` was a second FR-004
implementation nothing called — and it owned the entire precedence and scheme-rejection suite,
so those eight tests were green against code the CLI never ran while the shipped
`resolveInstanceUrl` had no precedence coverage at all. Suite moved onto the real function.
Also: `writeJsonTarget`'s path-only wrapper (spec-only caller), and `ResolvedInputs.prompted`,
written at eight sites and read at none.

**Smaller.** `http.ts` defined a third private copy of the success-status rule that
`fetch-retry.ts` was written to centralise. `classify()`'s ternary returned `'exited'` from both
arms. `connect.ts` re-split and re-parsed the whole buffer per chunk; it now consumes completed
frames only. `NoConfigPathError` told developers to re-run with `--project`, a flag that does
not exist. `MalformedConfigError` sniffed the format from the file extension when both callers
know it. The summary's target column was padded to the longest id that happens to exist today.
`warn()` was a byte-identical copy of `fail()`; `canPrompt()` was called twice in a row. Four
near-identical `TargetOutcome` literals became one builder. `buildEntry` moved out of
`json-target.ts`, which the TOML writer had to import from.

Three doc blocks were stranded or inverted by earlier edits — one claimed the file is "parsed
rather than text-spliced" above a function in a file that now splices, one sat above the wrong
function entirely, and `printBanner` claimed to match create-app's banner, which it does not.

Deliberately NOT done, reported instead: ~120 ms of startup spent eagerly loading
inquirer/cfonts/ora (real and measured, but small against npx's own overhead and it would
scatter `await import()` through the handler); adopting `endpoints.ts` in create-app, which
still defines the same two paths twice more; `redact.ts` and `SkillsResult.command`, both
unreferenced — those are unimplemented requirements, not cleanup.

sdk-cli 234 · http 39 · sdk-create-app 132 · lint clean · verify-package 8/8 · format clean,
plus an end-to-end run against a folder holding other MCP servers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Vitest 4, `@nx/vite` and a registered `@nx/vitest` plugin were already in this workspace, and
several SDK libraries run on them — so this follows the house pattern rather than introducing a
runner. Each package declares an `nx:run-commands` target running `vitest run`, the same shape
`libs/sdk/vue` uses. Both were pinned into `nx.json`'s `@nx/jest/plugin` include list; those two
globs are gone.

Two pieces of configuration existed only to work around Jest and are simply deleted:

- `transformIgnorePatterns: []`. chalk 5 and ora are ESM-only, Jest skips `node_modules` by
  default, so they arrived untransformed and failed to parse. Vite serves ESM natively.
- `ts-jest` with a `module: commonjs` spec tsconfig. Vite transpiles with esbuild.

The `@dotcms/http` alias moves from `moduleNameMapper` to `resolve.alias` — a plain alias rather
than `vite-tsconfig-paths`, which crawls every tsconfig in the monorepo during @nx/vite's graph
inference and has segfaulted the native resolver on CI (the warning is written up in
`libs/sdk/analytics/vite.config.mts`). One entry is all this needs.

Spec migration was mostly mechanical `jest.*` -> `vi.*`, with three real changes:

- `vi.mock` factories are async and take `importOriginal`, so `jest.requireActual` inside a
  factory becomes `await importOriginal<typeof x>()`.
- `test-setup.ts` computed `PROJECT_ROOT` from `__dirname`, a CommonJS global that does not
  exist in the ESM Vite serves. Now `fileURLToPath(import.meta.url)`.
- Two comments explained the module-factory workaround as a ts-jest limitation. The reason is
  real but not ts-jest's: an ES module namespace object is non-configurable, so `spyOn` on it
  throws under either runner. Reworded to say what is actually true.

`defineConfig` comes from `vitest/config`, not `vite`: in Vitest 4 the `/// <reference
types='vitest' />` comment no longer augments Vite's `UserConfig`, so a `test` block does not
type-check against `vite`'s export. Both spec tsconfigs now typecheck clean, which they are
included in and previously were not exercised for.

`verify-package` stays a `dependsOn` of `test`, so the packaging invariants still gate on
`nx test` — that is the only place they run. CI invokes `nx affected -t test` with no
`--configuration`, so dropping the old `ci` configuration changes nothing.

sdk-cli 234 · http 39, both green, lint clean, verify-package 8/8, production build fine,
format clean. The repo-pollution backstop was re-verified by planting a forbidden file and
confirming the suite fails — `setupFilesAfterEnv` became `setupFiles` and that guard is the one
piece of setup whose silence would be indistinguishable from success.

http runs in ~125 ms where Jest took seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Setup prompting, MCP initialization, secret-file handling, failure reporting, and spec alignment have unresolved correctness and security issues.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds the dotcms agent setup CLI for configuring supported IDE agents, backed by shared HTTP utilities and SDK publishing updates.

Changes:

  • Adds agent detection, authentication, secure config merging, skills installation, and MCP verification.
  • Extracts reusable HTTP/authentication utilities and migrates create-app.
  • Adds extensive tests, packaging checks, documentation, and release-pipeline support.
File summaries
File Description
.github/actions/core-cicd/deployment/deploy-javascript-sdk/action.yml Supports unscoped package publishing.
core-web/apps/mcp-server/README.md Documents quick setup command.
core-web/libs/http/README.md Documents shared HTTP library.
core-web/libs/http/eslint.config.mjs Adds lint configuration.
core-web/libs/http/package.json Defines internal HTTP package.
core-web/libs/http/project.json Configures HTTP build and tests.
core-web/libs/http/src/index.ts Exports HTTP APIs.
core-web/libs/http/src/lib/endpoints.ts Centralizes dotCMS endpoints.
core-web/libs/http/src/lib/fetch-retry.spec.ts Tests transport diagnostics.
core-web/libs/http/src/lib/fetch-retry.ts Expands failure descriptions.
core-web/libs/http/src/lib/http.spec.ts Tests fetch client behavior.
core-web/libs/http/src/lib/http.ts Implements shared fetch client.
core-web/libs/http/src/lib/result.ts Adds result primitives.
core-web/libs/http/tsconfig.json Adds TypeScript project references.
core-web/libs/http/tsconfig.lib.json Configures library compilation.
core-web/libs/http/tsconfig.spec.json Configures test compilation.
core-web/libs/http/vite.config.mts Configures Vitest.
core-web/libs/sdk/cli/README.md Documents the new CLI.
core-web/libs/sdk/cli/eslint.config.mjs Enforces dependency boundaries.
core-web/libs/sdk/cli/package.json Defines publishable CLI package.
core-web/libs/sdk/cli/project.json Configures build, test, and publishing.
core-web/libs/sdk/cli/scripts/verify-package.sh Validates publish artifacts.
core-web/libs/sdk/cli/src/commands/agent/connect.spec.ts Tests MCP connection verification.
core-web/libs/sdk/cli/src/commands/agent/connect.ts Launches and probes MCP server.
core-web/libs/sdk/cli/src/commands/agent/constants.ts Defines agent setup constants.
core-web/libs/sdk/cli/src/commands/agent/gitignore.spec.ts Tests repository protection.
core-web/libs/sdk/cli/src/commands/agent/gitignore.ts Adds config files to .gitignore.
core-web/libs/sdk/cli/src/commands/agent/index.ts Registers agent command options.
core-web/libs/sdk/cli/src/commands/agent/prompts.spec.ts Tests prompt compatibility.
core-web/libs/sdk/cli/src/commands/agent/prompts.ts Adapts Inquirer prompts.
core-web/libs/sdk/cli/src/commands/agent/setup.spec.ts Tests complete setup workflow.
core-web/libs/sdk/cli/src/commands/agent/setup.ts Orchestrates agent setup.
core-web/libs/sdk/cli/src/commands/agent/skills.spec.ts Tests skills installation.
core-web/libs/sdk/cli/src/commands/agent/skills.ts Invokes skills installer.
core-web/libs/sdk/cli/src/commands/agent/targets/entry.ts Builds MCP entries.
core-web/libs/sdk/cli/src/commands/agent/targets/json-target.spec.ts Tests JSON target writing.
core-web/libs/sdk/cli/src/commands/agent/targets/json-target.ts Writes JSON configurations.
core-web/libs/sdk/cli/src/commands/agent/targets/registry.spec.ts Tests editor registry.
core-web/libs/sdk/cli/src/commands/agent/targets/registry.ts Describes supported editors.
core-web/libs/sdk/cli/src/commands/agent/targets/toml-target.spec.ts Tests TOML preservation.
core-web/libs/sdk/cli/src/commands/agent/targets/toml-target.ts Writes Codex TOML configuration.
core-web/libs/sdk/cli/src/commands/agent/targets/types.ts Defines target contracts.
core-web/libs/sdk/cli/src/commands/agent/targets/writers.ts Registers format writers.
core-web/libs/sdk/cli/src/index.ts Adds CLI entry point.
core-web/libs/sdk/cli/src/shared/__fixtures__/appconfiguration.ts Adds realistic API fixture.
core-web/libs/sdk/cli/src/shared/auth.spec.ts Tests token authentication.
core-web/libs/sdk/cli/src/shared/auth.ts Mints and verifies tokens.
core-web/libs/sdk/cli/src/shared/config-file.spec.ts Tests safe JSON merging.
core-web/libs/sdk/cli/src/shared/config-file.ts Implements JSONC configuration writes.
core-web/libs/sdk/cli/src/shared/env.ts Centralizes environment access.
core-web/libs/sdk/cli/src/shared/errors.spec.ts Tests diagnostic errors.
core-web/libs/sdk/cli/src/shared/errors.ts Defines CLI errors.
core-web/libs/sdk/cli/src/shared/instance.spec.ts Tests instance validation.
core-web/libs/sdk/cli/src/shared/instance.ts Validates instance and compatibility.
core-web/libs/sdk/cli/src/shared/prompts.spec.ts Tests input resolution.
core-web/libs/sdk/cli/src/shared/prompts.ts Resolves required inputs.
core-web/libs/sdk/cli/src/shared/redact.spec.ts Tests token redaction.
core-web/libs/sdk/cli/src/shared/redact.ts Redacts secrets.
core-web/libs/sdk/cli/src/shared/types.ts Defines setup data types.
core-web/libs/sdk/cli/src/shared/ui.spec.ts Tests summary output.
core-web/libs/sdk/cli/src/shared/ui.ts Renders progress and summaries.
core-web/libs/sdk/cli/src/shared/version.ts Exposes build-time CLI version.
core-web/libs/sdk/cli/src/test-setup.ts Prevents test filesystem leakage.
core-web/libs/sdk/cli/tsconfig.json Adds CLI TypeScript references.
core-web/libs/sdk/cli/tsconfig.lib.json Configures CLI compilation.
core-web/libs/sdk/cli/tsconfig.spec.json Configures CLI test compilation.
core-web/libs/sdk/cli/vite.config.mts Configures CLI Vitest tests.
core-web/libs/sdk/create-app/jest.config.ts Maps shared HTTP imports.
core-web/libs/sdk/create-app/src/api/index.ts Migrates API calls to shared HTTP.
core-web/libs/sdk/create-app/src/index.ts Migrates shared utilities.
core-web/libs/sdk/create-app/src/utils/index.ts Uses shared HTTP and results.
core-web/libs/sdk/create-app/src/utils/install.spec.ts Updates result imports.
core-web/libs/sdk/create-app/src/utils/install.ts Uses shared result type.
core-web/libs/sdk/create-app/src/utils/readiness.ts Uses shared status checks.
core-web/libs/sdk/create-app/src/uve/configure-uve.spec.ts Updates HTTP mocks.
core-web/libs/sdk/create-app/src/uve/configure-uve.ts Uses shared HTTP client.
core-web/package.json Adds parser dependencies.
core-web/pnpm-lock.yaml Locks new dependencies.
core-web/tsconfig.base.json Adds workspace aliases.
specs/37390-dotcms-agent-setup/contracts/cli-interface.md Defines CLI contract.
specs/37390-dotcms-agent-setup/contracts/target-configs.md Defines editor configurations.
specs/37390-dotcms-agent-setup/data-model.md Defines setup data model.
specs/37390-dotcms-agent-setup/spec.md Specifies feature requirements.
Review details

Files not reviewed (1)

  • core-web/pnpm-lock.yaml: Generated file

Suppressed comments (3)

core-web/libs/sdk/cli/src/commands/agent/setup.ts:196

  • If no editor is detected in a non-interactive run, this leaves targets empty. The command can then run the standalone connection check, return exit 0, and even print “Ready” although no editor was configured. The specified edge case requires a named message directing the user to choose --agent; do not silently treat this as success.
    core-web/libs/sdk/cli/src/shared/config-file.ts:223
  • The token is written before permissions are restricted. On POSIX, a new file is initially created with 0666 & umask (commonly 0644), so other users can read it until chmod runs, and a crash in between leaves it exposed permanently. Create an owner-only temporary/file descriptor first (mode 0600) and then write/rename it.
    core-web/libs/http/src/lib/http.ts:114
  • The abort timer is cleared before response.text() consumes the body. A server that sends headers and then stalls can therefore hang both CLIs indefinitely despite the request timeout. Keep the timer active through body consumption.
  • Files reviewed: 81/83 changed files
  • Comments generated: 11
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

lines: string[],
containerKey: string
): { start: number; end: number } | null {
const ours = new RegExp(`^\\s*\\[\\s*${containerKey}\\.${ENTRY_KEY}\\s*(\\.[^\\]]+)?\\]`);
Comment on lines +137 to +141
await fs.writeFile(file, next.endsWith('\n') ? next : `${next}\n`, 'utf8');
// Report what chmod actually did. The flow used to assert `CAN_RESTRICT` here, which is a
// platform constant rather than an observation — the summary claimed a protection nobody
// had checked.
return { path: file, permissionsApplied: await restrictFile(file), replacedExisting };
Comment on lines +201 to +203
const tree = raw === null ? undefined : parseTree(raw, [], PARSE_OPTIONS);
if (raw === null || raw.trim() === '' || tree?.type !== 'object') {
next = `${JSON.stringify({ [args.containerKey]: { [args.entryKey]: args.entry } }, null, 2)}\n`;
export function validateUrl(url: string): void {
if (!/^https?:\/\//i.test(url)) throw new InvalidUrlError(url);
try {
new URL(url);
}
})}\n`
);
child.stdin?.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' })}\n`);
const detected = await detectTargets();
const detectedById = new Map(detected.map((t) => [t.id as string, t]));
if (opts.promptPort) {
const chosen = await opts.promptPort.multiSelect(
Comment on lines +294 to +295
// 7. Skills — non-fatal by design (FR-026).
if (!opts.skipSkills) {
Comment on lines +317 to +321
o.skillsInstalled = !skills.ok
? 'no'
: target?.skillsLocationVerified
? 'yes'
: 'unverified';
Comment on lines +333 to +335
const result = await confirmConnection({ url, token: token.value });
connection = result.ok ? 'ok' : 'failed';
if (!result.ok) connectionReason = `${result.cause}: ${result.detail}`;
@@ -1,5 +1,6 @@
import { httpGet, httpPost } from '@dotcms/http';
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

dotbot code review:

  • Reviewer: meta/muse-spark-1.3 (medium)
  • Overall: patch is incorrect
  • New findings this run: 0
  • Prior unresolved dotbot findings still relevant: 1
  • Active findings total: 1

No new actionable bugs were found in the current changes, but 1 prior unresolved dotbot finding still applies, so the patch remains incorrect.

Tip: comment with "/dotbot address comments" to attempt automated fixes for unresolved review threads.

reviewed by dotbot · meta/muse-spark-1.3 · medium

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

dotbot code review:

  • Reviewer: ~z-ai/glm-latest (medium)
  • Overall: patch is incorrect
  • New findings this run: 0
  • Prior unresolved dotbot findings still relevant: 1
  • Active findings total: 1

No new actionable bugs were found in the current changes, but 1 prior unresolved dotbot finding still applies, so the patch remains incorrect.

Tip: comment with "/dotbot address comments" to attempt automated fixes for unresolved review threads.

reviewed by dotbot · ~z-ai/glm-latest · medium

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

Labels

Area : CI/CD PR changes GitHub Actions/workflows Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries PR : dotbot review Trigger dotbot AI code review on this PR

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Add dotcms agent setup — one command to connect an IDE to dotCMS

2 participants