Skip to content

feat(sdk)!: keep your edits when regenerating an SDK - #445

Closed
asadali214 wants to merge 69 commits into
devfrom
asadali214/custom-code-injection
Closed

asadali214 wants to merge 69 commits into
devfrom
asadali214/custom-code-injection

Conversation

@asadali214

@asadali214 asadali214 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What

apimatic sdk generate keeps the developer's edits to a generated SDK across regenerations. Edit any file, any line; change the spec; run the same command again, and the generator's changes are merged into the edited files, new files are added, and files the generator stopped producing are removed when they were not edited. Overlapping changes become git-style conflicts: markers in the file, the files listed, exit code 1, and a refusal to run again until the files are resolved and staged.

Design: .ai/plans/custom-code.md in this branch. Tracking: apimatic/codegen-v2#675.

Breaking changes

  • sdk generate --zip is removed. It wrote the archive the server sent and nothing else, which custom code could never apply to. Generate into a directory and archive it yourself. portal generate --zip is untouched.
  • --destination names the SDK directory itself, for sdk generate and sdk publish. Before, the CLI added <language> below it, so -d ./sdks -l python wrote ./sdks/python; now it writes ./sdks, so a clone of an SDK repository can be named and regenerated over. Without the flag the directory is <input>/sdk/<language>, as before. Two languages need two destinations.
  • sdk generate --api-version is removed. The command no longer reads APIMATIC-BUILD.json to pick a versioned build; every SDK is built from the project's source directory.

How it works

  • Git 2.38+ on PATH is the engine. Without it the command behaves exactly as before, writes no .git and no .apimatic/, and warns that customizations will not persist.
  • Snapshots as hidden refs. Each run records the SDK exactly as generated as a commit under refs/apimatic/pristine/<sdk-path> in the repository that encloses the SDK directory, every path segment percent-encoded so any directory name makes a valid ref. When nothing encloses the directory, or the enclosing repository ignores it, a terminal run asks on the first run whether to create a repository there; --force or no terminal writes the SDK without one and warns that edits will not persist. The directory as it was before the run goes under refs/apimatic/previous/<sdk-path>. No branch is created or checked out, and no commit is needed before regenerating.
  • Shared by a pre-push hook. Every recorded run writes the CLI's script, .git/hooks/apimatic-pre-push, and a three-line block in .git/hooks/pre-push that calls it (fresh, or after the shebang of an existing shell hook), and says so the first time the hook file is written. From then on every git push also pushes refs/apimatic/pristine/* to that remote; a chain another machine pushed first is fetched into tracking refs and joined with a two-parent commit, and a server that refuses the ref gets one line in its own words while the branch still lands. The CLI itself never pushes. Its only remote operation is a fetch, without prompts, with the developer's own SSH client, and with a 30-second timeout, when a record names a commit the repository lacks, so a clone or a CI checkout merges by itself. With core.hooksPath set (husky, or a ~/.githooks of the developer's own) or a non-shell hook, the run shows a next-steps note with the one line to add to the hook that does run; the note stops once it is there.
  • The record. <sdk>/.apimatic/generation.json names the pristine commit the directory was written from, so a branch, worktree or clone merges against the right base. It holds only what the merge needs, so a regeneration that changed nothing leaves it untouched. It is committed with the SDK, and merges as binary so a pull between two clones that both regenerated never writes markers into it. When that commit is neither local nor on the remote, the CLI says where it looked, then asks before replacing.
  • The merge is git's. git merge-tree --write-tree between two temporary commits (disk and fresh generation) on the pristine base, run with the CLI's own -c settings so the developer's config cannot change the outcome; renames paired by content on both sides; add/add, modify/delete and binary conflicts as git reports them. Conflicted paths are staged into the index at stages 1/2/3, so git status shows them as both modified and git mergetool works. Each file is written back with the line ending it has on disk, or the generator's copy's, so CRLF files stay CRLF.
  • Writes are undone when one fails. Removals go first, so a case-only rename lands on Windows and macOS, and empty directories are pruned. A write or removal that fails stops the run and puts back every path it had changed, from the previous ref; if that fails too, the message gives the restore command.
  • Conflicts ask. Before anything is written, a run with conflicts lists them and asks: resolve by hand (markers, exit 1; the --force and CI default), the generator's changes where they conflict, or your own changes where they conflict, or cancel the run (nothing written, nothing recorded). For either side, each marked block is settled for that side as the file is written, so only the overlapping hunks go that way and the other edits in the same file survive; a file one side deleted, or a binary one, is settled whole, and a customized file the generator no longer produces is kept whichever side is chosen. The next run, while git holds any of the SDK's files as unmerged, asks before generating: finish resolving, or settle what remains for either side, in place and without network. The run refuses outright while a git merge, rebase, cherry-pick or revert is in progress, and never reads git's own markers as the CLI's.
  • --force still means never ask, and never replaces an SDK that has a generation record: a record for another generation, or one whose snapshot is missing, fails the run before the upload, with the way out named.
  • sdk publish generates or merges into the SDK directory, then uploads the files git would commit below it: .git, .apimatic/ and anything the SDK's .gitignore excludes stay out. A dry run does the same on the same directory and stops before the upload, so what it opens is what a real run uploads.

Shape of the change

Command → Action → Prompts/Types → Infrastructure, per .ai/instructions.md:

  • GenerateAction.execute is the whole run, read top to bottom; every question and message is in it. It asks SdkContext.destination how the run will write (SdkDestination: overwrite, record, or merge, or unresolved), before the upload.
  • SdkRepositoryContext (types) is the SDK directory with its repository: inspect, snapshots, record, merge, write, staging, settling, the hook, the files to share. SdkContext hands one back, and zips the SDK for upload.
  • GitService (infrastructure) is the only place that spells git commands and parses their output, including the remote ones.
  • MergePlan, Settlement and the conflict markers (src/types/sdk/) are pure: conflict kinds, taking(side), marker relabelling and settling, what to report. ShareHook is the script, the calling block, and how the block goes into an existing hook. terminal.isInteractive (src/utils/) is the one check behind every question.
  • RelativeFilePath (types/file) is a path as git names it inside a tree, with in(directory) to the file on disk.

Verification

  • 2,056 tests, 2,049 passing on this machine; the 7 failures are the commitlint-config suite's local plugin-resolution issue that predates this branch. Most of the new ones run over real git repositories, most over real git repositories: every row of the plan's merge table, both rename directions, case-only renames, CRLF and non-UTF-8 files, binary, index staging, bare remotes with --no-local clones for the fetch, the hook driven by real pushes (shared, joined, refused, alongside an existing hook, under ~/.githooks), a failed write undone, the publish upload's contents, and the action flows with a stubbed generation service.
  • A 30-case lab run against the hosted generator with the real CLI (the Speakeasy lab's cases, thirteen edge cases, seven of them about sharing, and one case per answer to the two conflict questions, answered by a harness in place of a terminal), recorded with every command's exact output: APIMatic Customization Lab (private; ask for access). Recorded before the review follow-ups; the fixed behaviours are covered by the tests above.
  • pnpm lint, pnpm build, and tsc --noUnusedLocals --noUnusedParameters clean for the new code.

Still to decide (plan §11)

  • A file the developer deleted comes back as a listed conflict when the generator changes it (git semantics), rather than a remembered deletion.
  • git init on the first run tells the developer afterwards rather than asking.
  • Review items: SdkContext.directory() accessor (the context skill's exception case).

🤖 Generated with Claude Code

asadali214 and others added 20 commits October 20, 2026 10:52
Snapshots of an SDK directory as commits under hidden refs of the
repository enclosing it: the pristine chain, one commit per generation,
and the previous state before a run writes. A generation record in
.apimatic/generation.json names the snapshot the directory was written
from. Nothing calls it yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace the isomorphic-git pristine store with a GitService that drives
the installed git (2.38 or newer) through its plumbing, and add the
three-way merge of a regenerated SDK over the developer's edits:
`git merge-tree --write-tree` between two temporary commits on the
pristine base, its output parsed into a merge plan of writes, deletions,
renames and conflicts by kind, with the markers relabelled for the
developer. Every git call carries fixed settings so the developer's own
configuration cannot change a snapshot or a merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`sdk generate` now merges a regeneration over the developer's changes
instead of emptying the directory. Before the upload, SdkPrepareAction
checks for git 2.38 or newer, inspects the SDK directory through the
pristine store and decides how the run will write: as before, when git
is absent (with a warning that customizations will not persist) or
with --zip; written and recorded, when there is no base yet; or merged
against the snapshot the generation record names. After the download,
SdkWriteAction writes it that way, initialises a repository in the SDK
directory when nothing encloses it, records the pristine snapshot and
`.apimatic/generation.json`, and reports conflicts with markers, a list
of files and exit code 1. An overwrite keeps the directory's `.git`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A conflicted regeneration now puts git's three versions of each
conflicted path into the repository's index through
`update-index --index-info`, so `git status` reports them as both
modified and `git mergetool` opens them, and the command tracks a
telemetry event with the counts by kind. An SDK whose recorded
snapshot the repository does not hold is told which commit is missing
and how a teammate pushes and the reader fetches the
`refs/apimatic/pristine/*` refs before it is replaced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The plan behind keeping edits across regenerations, in `.ai/plans/`,
the four terms it introduces in `CONTEXT.md`, a README section and the
git requirement, and one more sentence in the command's description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The previous ref's tree is rooted at the SDK directory, so the restore
needs the work tree pointed there, as the lab verified.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The summary after a merge names the files that were merged with the
developer's changes and the ones kept as edited, as paths relative to
where the command ran, and counts the generator's own files and the
conflicts; the conflict block lists paths the same way and its steps
match the kinds present. A generation identical to the last keeps the
last pristine commit instead of chaining a new one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A path inside a git tree, as `merge-tree`, `diff-tree` and the
generation record name it, is now a value object: validated once where
git's output and the record are parsed, turned into a file on disk
through `in(directory)`, re-rooted for the index through `below`, and
never a bare string in the merge plan, the services or the prompts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The merge, its application to disk and the index staging work on the
same repository, base and trees the snapshots do, so they are methods
of the store; the write action holds one service.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The methods of the pristine store all took the repository as their
first argument and read none of the store's own state, so they are the
repository's: SdkRepositoryContext holds the git and file services the
way SdkContext does, and SdkContext hands one back (found or
initialised). The store and the SdkRepository value object are gone;
the two tests that read snapshots use a small helper over GitService.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
src/application/ held four files with one caller, and both Types and
Infrastructure imported from it, against the layer order. MergePlan and
its types move to src/types/sdk/merge-plan.ts, with planMerge as
MergePlan.from, and the conflict markers move beside it. GitService
reads merge-tree's output itself, as diffTrees and listTree already do,
and diffTrees returns the shared Change type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After every recorded run the CLI pushes refs/apimatic/pristine/<path> to the
branch's remote (or origin) and says so when it sent something. A run whose
record names a commit the repository lacks fetches the ref first, so a clone
or a CI checkout merges by itself. Chains that diverged because two machines
generated are joined with a two-parent commit; nothing is ever forced.

Remote commands run without prompts and with a 30-second timeout, so a
missing credential, a refused push or a dead remote is a warning with git's
reason and the manual push, never a stuck or failed run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The SDK prompts took SdkRepositoryContext, decided which conflicts leave
markers, and built git refspecs themselves. They now take the directory
and refs they print; MergePlan answers hasMarkedConflicts and
hasUnmarkedConflicts and names the generator's files and yours; and
SnapshotRef gives its refspecs, which GitService.pushRef uses too. What
the prompts print is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CommitId, TreeId and GitVersion know nothing of SDKs, and the shapes
GitService reads from diff-tree and merge-tree (Change, StageEntry,
MergeMessage, MergeTreeOutput) sat in merge-plan.ts, so the git service
imported its own output types from an SDK file. They move to
src/types/git/, the four shapes as merge-tree.ts, where StageEntry names
its stages ours and theirs as git does. SnapshotRef, the conflict
markers and MergePlan stay in src/types/sdk/: they name the SDK's
snapshots, your side and the generator's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The write action set its own closing line for a run left with
conflicts; the wording now lives in the write prompts, which the
action imports. Comments in the prepare and write actions are cut to
one line each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A missing snapshot, a refused push, a nested SDK directory, a generation at
another stability and the conflict steps no longer show commit ids, ref names,
git's status words or the manual commands; the README keeps the manual push
and the undo command. "Pushed" is one line whether or not chains were joined,
and the fetch is no longer announced.

The data the prompts no longer need went with it: the missing snapshot's
commit, where a base was fetched from, the joined push outcome, and the nested
refusal's prose reason, which is now a NestedSdkDirectory naming the other
directory and whether it encloses this one. Remote errors carry the server's
own words. The description of two generations side by side lives with the
generation type, so the prompts stay wording.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The flag wrote the archive the server sent and nothing else, which custom
code could never apply to, and nobody needs the CLI to zip a directory.
SdkContext.save only copies now and cannot fail with a result, so the
"could not be saved" prompt and SdkSaveProblem go with it; the destination's
overwrite kind has no zip field. The server still delivers a zip, which the
CLI unpacks into a temp directory, and portal generate --zip is untouched.

SdkWriteAction's three destinations are written out in its switch instead
of three private methods.

BREAKING CHANGE: `apimatic sdk generate --zip` is no longer accepted.
Generate into a directory and archive it yourself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…om the CLI

The CLI no longer pushes. After every recorded run it writes its script,
.git/hooks/apimatic-pre-push, and a three-line block in .git/hooks/pre-push
that calls it, quietly. Every git push then also pushes refs/apimatic/pristine/*
to the remote being pushed to; a chain another machine pushed first is fetched
into refs/apimatic/remotes/<remote>/pristine/* and joined, and a server that
refuses the ref gets one line in its own words while the branch still lands.
The snapshot and the record that names it now reach the remote together, so a
remote attached after generating, or a push that once failed, no longer leaves
clones without a base.

When git runs hooks from elsewhere (core.hooksPath, as husky sets it) or the
existing hook is not a shell script, the run shows a next-steps note with the
one line to add; the note stops once that file runs the script. The CLI's only
remote operation left is the fetch for a record whose commit is missing locally.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Before anything is written, a run with conflicts lists them and asks: resolve
by hand (markers, exit 1; the --force and no-terminal default), the generator's
changes where they conflict (the merge run again with --strategy-option=theirs,
so only the overlapping hunks go the generator's way; a file one side deleted
is settled whole), or keep the SDK as it is (nothing written, nothing recorded).
The next run, while markers remain, asks again: finish resolving, the
generator's side inside the remaining markers (settled in place, no network),
or undo the last generation (the directory back as refs/apimatic/previous
holds it, through a temporary index). Files the CLI settles leave git's
unmerged state. The choice rides on the conflict telemetry event.

The summary lists files you added under "Kept as yours", next to the ones you
edited; the record directory is never listed. The README's custom-code section
is gone: the CLI's own messages carry what a developer needs, and the feature
docs belong in the v4 docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The SDK run is one `GenerateAction.execute`, read top to bottom, and the
only place that speaks: every question and message is a prompts call in
it, and the action has no other method. The prompts word the data they
are handed and decide nothing.

What a state means is pure and lives in `src/types/sdk/`:
`SdkDestination` carries its reason, `destinationFor` maps a repository
state to it (`unresolved` included), `replacesDirectory` is what the
overwrite question is about, and `Settlement` (`resolving`,
`favouringGenerator`) is the two ways a conflicted merge becomes
writable, remembering the files to name once they are written. The I/O
is on the contexts: `SdkContext.destination`, and
`SdkRepositoryContext.regenerate` and `write`.

`SdkPrepareAction`, `SdkWriteAction` and their prompts are folded away;
`SdkMergeConflictedEvent`, `ConflictOutcome` and the result payload are
removed, so `execute` returns a plain `ActionResult`. The two action
tests drive `GenerateAction` through `test/helpers/sdk-generation.ts`;
the merge lab runs through `regenerate` and `write`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@asadali214 asadali214 self-assigned this Oct 22, 2026
@asadali214
asadali214 marked this pull request as ready for review October 22, 2026 12:37
asadali214 and others added 4 commits October 23, 2026 09:41
Three differences between a developer's machine and the runners failed
the same tests on every OS, before the previous commit as well:

- A bare origin made with `git init --bare` has `master` as HEAD on a
  runner, which nothing pushes, so a clone of it checks out nothing and
  every clone test saw no record. The tests now say `--initial-branch=main`.
- `os.tmpdir()` is an 8.3 alias on the Windows runner and a symlink on
  macOS; git answers with the real spelling, which the code already
  canonicalises. The tests now canonicalise their root the same way.
- `RelativeFilePath` refused `C:/a.ts` only on Windows, since
  `path.isAbsolute` is the platform's; it now asks both platforms.

The share hook is written as 0o700: only the user who pushes runs it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The second run asked the record for the files the last run left in
conflict and scanned them for the CLI's marker lines. Git's own rule is
simpler and is what every tool follows: a conflict is resolved when the
file is staged. The run now asks `ls-files -u` below the SDK directory
and nothing else, so an unstaged resolution still counts as unresolved,
a line that merely looks like a marker does not, and the record no
longer carries a local, transient state in a committed file.

- `GitService.unmergedPaths`; `RelativeFilePath.inside`
- `GenerationRecord` drops `conflicts` (older records still parse)
- `hasConflictMarkers` removed; `restorePrevious` takes the paths
- `resolveForGenerator` leaves an unmerged file without markers as it is
- second-run messages say "still unresolved"; first-run steps say to
  stage each file

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`execute` stays the one place that names every prompt, but four steps
now live below the class as free functions that take the prompts they
may use as arguments, the way `versionToBuild` takes `selectVersion`:
`settleMarkers`, `settleConflicts`, `recordSnapshot` (once, for both the
record and the merge branch) and `closeMerge`. A free function has no
`this`, so it has no other way to speak.

For that, no prompt method may use `this` any more: `attentionTitle`,
`conflictLines` and `steps` moved to module scope beside
`whereItWasLookedFor`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n speak for itself

- `download` takes the upload, the generation and the unzip out of the
  `withDirPath` callback; the spinner is handed over as the exact prompt,
  the service call as a closure over what only `execute` holds.
- `settleMarkers` is now `settleUnresolvedConflicts`: since unresolved
  conflicts are read from git's index, the step no longer looks at markers.
- Delegates are passed as `this.prompts.x` at each call site, so the
  prompt a step may use is named in full where it is handed over.
- The action carries no comments; its names say what they did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@asadali214

Copy link
Copy Markdown
Contributor Author

Follow-up in 88aa107: execute speaks every prompt itself again and the action has no other method, as the plan records; the phases that df0a848 made private methods are inlined, and recording moved into SdkRepositoryContext.record. root() did not stay after all: both contexts now expose only what the run calls, and the tests work from the paths they lay out.

asadali214 and others added 12 commits October 28, 2026 12:01
…ch kept

The merge commit a904c21 recorded the merge-base version of
src/types/project-context.ts, which no longer compiled against the
callers on either side. This is the three-way result: dev's buildZip,
pluginBuildZip and stagedBuild on this branch's two-argument context,
without the versioned reading helper the branch had removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ores

The archive used to take the list of files to include, read from git's
view of what it would commit, with a second directory walk in the SDK
context for the case without a repository. The zip service now walks the
directory itself and leaves out what it is told to: `.git`, the CLI's
record and, inside a repository, what the ignore rules keep out, listed
by `git ls-files --ignored --directory`. A wholly ignored directory is
pruned as one entry, while an ignored file beside something tracked is
left out on its own.

Directories get their own RelativeDirectoryPath beside RelativeFilePath,
and RelativePaths carries the two kinds together where git lists them in
one call and the zip service excludes both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ypes where they belong

The four effectful stores, the merge engine and the on-disk helpers move to
src/types/sdk-repository/; GitProblem, GitOperation, TreeEntry and the .git
name move to src/types/git/ so infrastructure imports from types, not the
reverse; the terminal adapter moves to infrastructure. Contexts reach the
disk through FileService only, SdkRepositoryContext composes every part in
its constructor, and an ESLint rule keeps src/types/sdk/ free of fs and
infrastructure imports.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mGit that throws

SdkRepositoryProblem becomes RepositoryProblem { reason }, which a GitProblem
already is, so nothing relabels git's words; only write can answer NotApplied,
and archive hands a git failure on as itself rather than as zipFailed.
GenerationRecordFile reads and writes plainly, wrapped by the context. Every
fromGit validates and returns undefined, and GitService turns that into an
err; topLevel and readBlobs return early instead of composing with andThen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ds around

BlobId and RemoteName join CommitId and TreeId; RelativeFilePath.under and
within take a RelativeDirectoryPath, SdkPath holds one and spells its strip
direction within; GitService takes an SdkPath where it means one, lists refs
as SnapshotRefs, and answers a canonical topLevel. A ValueMap keyed by what a
value spells replaces the string-keyed maps in MergePlan and the stores.
Field aliases and assign-then-return locals left by the earlier passes are
inlined.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the core

classify and afterFetch hold the precedence a run decides the repository's
state by, reconcile how two pristine chains meet, and destinationFor builds
every destination including overwrite and noRepository; the contexts only
gather. SdkDestination is generic over the handle the shell writes through,
so the core never names a context, and SdkRepositoryContext.create is total
over an SdkPath instead of answering undefined.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…run cannot ask

writeModeFor turns the destination and how the run was started into one
value the action dispatches on in a single switch, carrying the --force
refusal and the one question each mode still needs; the command decides
whether a run may ask and passes it in, so a non-interactive run without
--force now refuses at the overwrite, repository and conflict questions
instead of hanging, saving without a repository or writing markers. The
force flag's help says what the run does; the closing line of a conflicted
merge is spoken by the prompts; write and stageConflicts take the settlement
and regeneration whole, and inspect resolves its own remote.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mean

SdkGeneration is a value the command's flags build once, with no fallback
generator, and it owns equality, its descriptions and the flag that would
make two generations match; recordFresh snapshots and records in one step
and recordingNotice turns the outcome into one thing to say; the next steps,
the hook's remaining steps and the restore command come from the core as
data the prompts word through tables. The hook outcome names the directory
git runs hooks from as a path, and every prompt that took a reason string
takes the problem.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… and the plan as amended

The binary test, the latin1 round-trip and the line-ending rule live once in
the core and the store only reads the sample; the hook decision is one pure
update; chosen names the side it picked; the conflict style the markers rely
on is a shared constant, and the hook script and attributes text interpolate
the constants they repeated. The merge is settled by one function with one
cancellation, merge-tree output is parsed in one place into a closed union
with the stages named by role, and the markers parser and hook block are no
longer exported for tests alone. GitService's mode parameters become named
methods and its two runners one; the zip and temp contexts split the same
way. The publish destination is a DirectoryPath from the command and its
duplicate containment check is gone, each prompts class keeps its own
wording, stale comments go, and the plan's sections 2, 9 and 10 say what the
code now does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…now uses

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/infrastructure/file-service.ts Outdated
await fsExtra.writeFile(filePath.toString(), content);
}

public async chmod(filePath: FilePath, mode: number): Promise<void> {

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.

i don't think this belongs here. This is not related to files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have renamed this function, to make it clear that it belongs here

Shield-Jaguar and others added 11 commits October 29, 2026 10:30
…TTY comparison

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The question that offered to create a repository is a flag: --git-init
makes the SDK directory a repository of its own, and without it the run
writes the SDK and warns, whether or not it could have asked. sdk publish
always passes it when run with flags, and takes --git-init in its
step-by-step experience. Only the SDK directory's own repository counts;
one that merely encloses it is the developer's project and is left alone,
and the run says once when it found a repository the developer already had.

--force settles conflicts for the generator, this run's and those the last
run left unresolved. Resolving by hand succeeds, with steps that offer a
merge tool, since the conflicts are in git's index, and name the files with
markers and without; finishing the last run's conflicts later cancels. The
conflict labels say what happened and nothing about how the file is
written, and the warning about what the directory holds is gone.

With one SDK directory per repository, what served one below a root goes:
SdkPath, per-path and percent-encoded refs, the nested-SDK refusal,
SdkRepositoryContext.create, the sub-path branches of the tree snapshot and
of unmergedStages, and RelativeFilePath.under. The hook pushes the one
pristine ref, and the restore command runs git -C in the SDK directory,
which now holds the ref. The plan, CONTEXT.md and the README say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FileService.chmod becomes makeExecutable, which owns the owner-only mode
the share hook needs, since that is the only reason anything calls it.

Gone, with the tests that only exercised them: FileService
.getAvailableDirectoryPath, getDownloadsDirectory and its os-extensions
module, PluginConfigState.hasNoSourceRepository, ServiceError.getError with
the errors map only it read, ApimaticConfigContext.exists,
PortalArtifacts.none, SpecContext.save, ValueMap.keys,
RelativeDirectoryPath.isEqual, RelativeFilePath.compare, and isEqual on
BlobId, RemoteName and CommitId. Tests that compared commit ids with
isEqual now compare them with deep equality.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…' into asadali214/custom-code-injection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ith a warning

A recorded SDK whose record names another generation, or whose snapshot
is missing, has no base to merge against. --force refused it before the
upload; it now replaces it, as it does any SDK it cannot merge into, and
warns after the write that the customizations were not carried over: the
committed ones are still in its git history, the uncommitted ones are
gone. The run succeeds and records a fresh snapshot, so the next run
merges again. The flag's help, the README and the plan say so.

terminal.isInteractive returns false rather than undefined when stdin is
not a TTY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The save keeps only .git, but the overwrite question skipped every name
starting with a dot, so a first recording run in a repository of the
developer's own that held only .github or .husky removed them without
asking. SdkContext.exists now asks FileService.hasEntriesExcluding, which
counts what cleanDirectoryExcluding would remove; a directory holding only
.git still needs no question. The other commands keep directoryEmpty. The
plan says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run that left conflicts behind ended as whatever path it took: "I'll
resolve them myself" succeeded, "I'll finish resolving them" was
cancelled, and a run without a terminal facing the last run's unresolved
files failed. ActionResult.conflicted() closes all three in yellow, exit 1
as git merge gives conflicts, so a command chained after it does not run.
Backing out of the unresolved question now cancels, as it does at every
other question.

sdk publish checked the generation only for a failure or a cancellation,
so a conflicted one was zipped, uploaded with its markers and recorded as
published. It now stops before zipping and ends Conflicted too. The plan
says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run that wrote the SDK into a directory that is not a git repository
of its own warned before the upload that customizations would not
persist. It now ends with a Next steps box after the write instead:
changes are lost on the next regeneration; to keep them, rerun with
--git-init before changing anything, then customize. A run that fails
before writing says nothing about git, since nothing was written.

The save write mode's warnNoRepository becomes suggestGitInit. The plan
says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The box after a run without a repository of its own spoke as though
the developer would change the SDK. It now starts "To customize ...
and keep your changes when it is regenerated:", says --git-init needs
git 2.38 or newer, and then says to make the changes.

The steps after "I'll resolve them myself" end at "2. Rerun the
command." They no longer say nothing needs committing first, or offer a
third step to let the CLI resolve what is left: the rerun asks that.

The plan says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bJWNsUv1rfBVvNTuc7GMq
A run that leaves the developer something to do before its work is
done ended as Conflicted. The state is now named Action required, so it
is not tied to conflicts: ActionResult.actionRequired() and
isActionRequired(), ResultType.ActionRequired, mapAll's
onActionRequired and format.outroActionRequired. It is still yellow and
still exits 1.

sdk publish checks it under the new name and keeps saying the SDK
still has conflicts, since conflicts are the only way there. The plan
says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bJWNsUv1rfBVvNTuc7GMq
…or --git-init makes one

Every run began with git --version, so a machine without git, with one
too old or with one that did not answer warned or failed even when the
SDK directory had no repository and none was asked for, where git has
nothing to keep.

SdkContext.destination now takes gitInit and first reads from disk
whether the SDK directory has a .git of its own, a folder or a
worktree's file. Without one and without --git-init it answers
noRepository without running git, and the run ends with the Next steps.
SdkContext.repository() takes the same check first, so archive asks git
about ignore rules only inside a repository of its own. With a .git or
--git-init, git is checked as before. sdk publish run with flags passes
--git-init, so it still checks. The plan says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bJWNsUv1rfBVvNTuc7GMq
@sonarqubecloud

Copy link
Copy Markdown

@asadali214

Copy link
Copy Markdown
Contributor Author

This work is redesigned in this PR: #465

@asadali214 asadali214 closed this Nov 3, 2026
@asadali214
asadali214 deleted the asadali214/custom-code-injection branch November 4, 2026 05:53
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.

4 participants