Repository navigation
feat(sdk): keep your edits when regenerating an SDK - #465
Merged
Merged
Conversation
…ion and --zip Phase 0 of the custom-code redesign: the SDK directory is the --destination itself, else <input>/sdk/<language>, derived in one place for both SDK commands. - sdk generate: --api-version, --zip and the versioned build lookup are gone; a destination holding src is refused since the write empties it; without a terminal the overwrite question is not asked and the run fails naming --force. - sdk publish: passes the language, stability and directory through; a dry run generates into the SDK directory, not a numbered folder under Downloads. - SdkContext reduced to the directory, TempContext unzips, a terminal check replaces process.stdin.isTTY; BuildConfig, the Downloads helper and the zip save are removed; README regenerated. - The phased plan in .ai/plans/custom-code-phases.md. BREAKING CHANGE: sdk generate no longer takes --api-version or --zip, and --destination names the SDK directory itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`sdk generate --git-init` makes the SDK directory a git repository of its own after the save and records the generation under refs/apimatic/pristine, chained onto the last snapshot, so a later run has a base to merge against. A directory that is already a repository of its own is recorded into as well; one that only encloses the SDK is the developer's and is left alone. Git is looked for only when the directory has a .git of its own or the flag was given. Without a usable git, none on PATH or older than 2.38, the SDK is written as before with a warning that says which; a run that ends without a repository gives the steps that make one. A repository git will not inspect fails the run before generating; a failed init or snapshot is a warning, since the SDK is on disk. Phase 1 of the custom-code redesign (.ai/plans/custom-code-phases.md): GitService over git's plumbing, GitVersion with none, GitContext answering hasGitDirectory, isRepository(git) and recordSnapshot, and the prompts for the warnings, the initialised repository, a failed init or snapshot, and a repository git would not inspect. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A run into an SDK directory recorded under git merges the fresh generation over the directory as it is, with git's merge-tree against the recorded snapshot, and writes only what changed: your edits stay, the generator's changes land beside them, and the overwrite question is not asked. A file in conflict is listed with what it is and nothing is written; asking about it comes in the next phase. Each file keeps the line ending it has, else the generator's, a binary file is never merged line by line, and a folder a removal leaves empty goes with it. Before the write the directory is kept, byte for byte, under refs/apimatic/previous. A write that fails names the file, the directory is put back from that snapshot, and if that fails too the restore command is given. The new generation is then recorded under refs/apimatic/pristine, chained onto the base, with .apimatic/generation.json naming it; an identical generation keeps the last snapshot. A record for another generation, or a snapshot the repository no longer holds, is recorded afresh after the overwrite question, with a warning for the missing snapshot; --force replaces a recorded SDK it cannot merge into and says so. Phase 2 of the custom-code redesign (.ai/plans/custom-code-phases.md): GitContext answering generationRecord (none for no record), hasSnapshot, merge, keepPrevious, write and restore; GitService over the plumbing with the same settings on every call and trees normalised through a temporary attributes file; MergePlan from git's merge and diffs; GenerationRecord; the prompts for the summary, the conflicts, a failed write and its undo, and a replaced or unrecorded SDK. SdkContext is gone, GitContext.save keeping .git in its place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
….exe The diff and restore commands were built by hand around f.relativePath, whose single quotes are not quoting to cmd.exe. Render them through f.cmdAlt with a double-quoted path, as the plugin prompts do. Also tidy the prompts: sdkMerged lists the kinds in straight-line code instead of two lookup tables, plural moves to string-utils, and the git version is interpolated without an explicit toString. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rows for sdk generate The portal build spinner, the plugin publish contents line and the generation timeout message each spelt out their own singular/plural ternary; they now use plural from string-utils, with the same output. The skill reference tables still described the SDK generate prompts and action by the version selection this branch removed; the rows now name what the files do today. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A merge that conflicts now asks what should happen: resolve it yourself, with the files left as git merged them and markers labelled "Your changes" and "Generated by APIMatic"; resolve using the generator's changes or yours, where the overlapping lines go to that side and the rest of the merge stands; or cancel, writing nothing. A file a side deleted or a binary one is taken whole; a file the generator no longer produces stays yours for either answer, so an edit is never thrown away by a choice that keeps the rest. Without a terminal the conflicts are listed and nothing is written; --force resolves them for the generator. A merge left to the developer marks the conflicted paths in the SDK directory's own index as a merge would, so git status and a merge tool see them, lists the next steps and ends as "Action required" with exit code 1, a fourth outcome beside success, failure and cancellation. sdk publish ends the same way instead of uploading markers. Git's facts become objects in one place: each conflict carries its own stage entries and knows how it lands for a side, and a path git names that the merge leaves nowhere, such as the old name of a file it moved into a directory the generator renamed, is neither settled nor counted. merge.directoryRenames is pinned beside merge.conflictStyle so a developer's global config cannot change where such a file lands. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…regenerating A run into an SDK directory whose last merge was left to the developer finds the paths git still holds as unmerged before it reads the record, whatever the record says. Without a terminal and without --force they are listed and the run ends as Action required; --force settles them for the generator; in a terminal the developer chooses to finish by hand (Action required), to settle for the generator or for themselves, or backs out. Settling reads the disk and the two sides git kept in the index: markers still there settle in place, a file a side lacks or a binary one takes the chosen side whole with the generator's side keeping yours where it has none, and text whose markers are gone was resolved by hand and stays. The files then leave the unmerged state, and one more look stops the run if any is still unmerged. A repository in the middle of a merge, rebase, cherry-pick or revert of the developer's own is refused first, naming the operation and how to abort it. Phase 4 of the custom-code redesign (.ai/plans/custom-code-phases.md), refactored together with Phase 3: GitContext.inspect answers every question a run asks of the repository as one RepositoryState, so the action reads a state instead of five Results; GitOperation is a value, none-able like GitVersion; every plan carries the bytes that reach the disk, line endings decided by merge and settlementFor, so write(plan) lands a merge and a settlement alike and UnresolvedFile.settledFor answers a Settlement rather than bytes or undefined; the prompts render what they are handed, with sdkMerged given its counts and the steps as two prompts the action picks between; the context no longer forwards stageConflicts to the service. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…generating A run into a clone of an SDK repository finds a record naming a snapshot the clone does not hold, since refs under refs/apimatic/ are not cloned. Once nothing is in the way, the run fetches the pristine chain from the remote the branch pushes to, else origin, with no prompts, a batch-mode ssh unless the developer's own client is configured, and a 30-second limit. The fetched chain is adopted when there is none here or it continues the local one, kept when it is behind, and joined with a two-parent commit when the two diverged. The snapshot here afterwards: the run merges. Still absent: the warning says there is no remote, that the remote lacks it, or why the fetch failed in git's words, and the generation is recorded afresh. Every recording also writes .apimatic/.gitattributes beside the record, merging it as binary and marking it generated, so a pull that meets a teammate's record leaves no markers in it. Phase 5 of the custom-code redesign (.ai/plans/custom-code-phases.md). The fetch is a step of the run after the refusals and the unresolved block, GitContext.fetchSnapshot, so nothing reaches the network before a refusal; RepositoryState carries a RecordedSnapshot, the snapshot the record names with whether it is held and, once the remote was tried, where else it was looked for as a SnapshotFetch, which the one snapshotMissing prompt words through match; GitService gains remoteOf, fetchRef and isAncestor, with commitTree taking any number of parents for the join; the chain rule is meetChains, facts first, proven through git in the context's tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
After every recording the run writes the script apimatic-pre-push into git's own hooks directory and makes the pre-push hook git runs call it: a new shell hook when there is none, the call right after the shebang of a shell hook that is there, and nothing when the hook calls the script already. Where git runs hooks from elsewhere (core.hooksPath, husky's layout included) or the hook is in another language, nothing is written into it and the run names the file and the one line to add, with a commit step when that file is in the repository. The script pushes the pristine ref with --no-verify; when the remote's chain moved on it fetches that chain to a tracking ref, joins the two and pushes again; a second failure prints one line in the server's words, and the push never fails. A push that carries the ref itself is left to itself. The run says once that it added the hook, or what is still needed, or that it could not be written; later runs are silent. Every git call clears GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE, so a hook of the developer's own that runs the CLI cannot hand it another repository. Phase 6 of the custom-code redesign (.ai/plans/custom-code-phases.md). ShareHook holds the call line, the markers and the script's name beside the rule, found, which reads the facts first and answers one of five outcomes, each holding the one call to its handler for match, and the content for the hook file through toWrite; GitContext.shareOnPush, a step of the run after each recording, writes the script (its text beside the record's attributes) and the hook as the rule says and answers how the hook was left, a hook that could not be written included, since nothing about it stops the run; GitService.hooksDirectory and hooksRunFrom; FileService.makeExecutable; the one shareHook prompt wording each outcome, a hook already calling the script as nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ould commit sdk publish run with flags always passes --git-init, so the SDK directory becomes a repository of its own and a later publish merges the generation over your edits, as sdk generate does; the guided run (no flags, or --git-init alone) takes the flag and passes it on. The upload holds what git would commit: not .git, not the CLI's record and, inside a repository of its own, not what the SDK's own ignore rules keep out, such as node_modules; outside one, every file but those two, with git never asked. The dry run says that the directory holds exactly what a real run uploads. --force on both SDK commands now says what it does: never ask, conflicts resolved using the generator's changes, an SDK that cannot be merged replaced, with a warning. Phase 7 of the custom-code redesign (.ai/plans/custom-code-phases.md). GitContext.archiveForUpload stands beside save as its mirror (a save keeps .git, an upload leaves .git and the record out), reading the git version the publish action hands it, as generate does; keptOutOfUpload names the rest. GitService.ignoredBelow reads ls-files for what the ignore rules keep out, a directory ignored whole as one entry; RelativeDirectoryPath and RelativePaths carry it; ZipService.archive and TempContext.zip take what to leave out, nothing by default, with the zip service's helpers as module functions. SdkPublish.isGuided holds the guided-mode rule; FlagsProvider.sdkForce words --force for the SDK commands as the docs' CLI Reference does; dryRunNotice and sdkNotZipped reworded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bJWNvjd8T6iWD7ztRYi3o
… clear the leftovers CONTEXT.md names the SDK directory, the pristine snapshot, the generation record and custom code. The design document .ai/plans/custom-code.md is back from the first attempt and made true for this branch: the phases and their commits, the files, the architecture, and every name the first attempt had replaced by the current one, with the one-context decision amended and dated. The four SDK flag descriptions now say what their rows in the docs' CLI Reference say, both commands carry the docs' sentence on keeping your changes, and the README names git 2.38 under Requirements and is regenerated. The conventions the phases followed are in .ai/instructions.md and the skills: Promise<Result> with early returns, no private instance methods, no middlemen, functional core and imperative shell, one prompts class per action, flag wording that matches the docs. Phase 8 of the custom-code redesign (.ai/plans/custom-code-phases.md). An ESLint rule keeps src/types/sdk, src/types/git and src/types/file free of file, process and infrastructure imports. The leftovers of the first attempt: terminal.isInteractive() is the one TTY check (the plugin action, the not-found hooks and portal serve read it; the plugin prompts' canAsk is gone); PluginConfigContext.hasNoSourceRepository, which only a test called, is removed; ServiceError drops the error map nothing read, so badRequest takes the message alone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bJWNvjd8T6iWD7ztRYi3o
asadali214
marked this pull request as ready for review
November 3, 2026 09:39
v4 is the one generator, so the flag chose nothing: it was parsed and dropped. A run that still passes it is told the flag is unknown. The generation record never named the version on this branch, and keeps not to. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pre-push hook and the script it calls were set to 0o755 whatever mode they had. SonarCloud flags that as world-accessible, which failed the PR's quality gate, and on a developer's own owner-only hook it widened who may read and run it. makeExecutable now adds an execute bit beside each read bit and keeps the rest of the mode, so a new hook is 0o755 under the usual umask and an owner-only hook stays owner-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bJWNvjd8T6iWD7ztRYi3o
When a merge's write fails and the directory cannot be put back, the command the developer is given now reads `git -C "<sdk>" restore --source=refs/apimatic/previous --worktree -- .`, the ref the run kept the directory under, as the design names it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A regeneration that recorded the same snapshot rewrote `.apimatic/generation.json` with the same bytes, so editors and file watchers saw a change. It now goes through the same if-changed write as the attributes beside it, as the design said it should. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ions The error a git that will not answer stops the run with reads as it did before the redesign: "The SDK directory could not be inspected for your customizations: <git's words>". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…"…: ." A conflict settled as git placed it, or by removing the file again, landed on disk but counted nothing, so a run whose only change was one of those summed up as "Regenerated the SDK at <sdk>: .". The placed file now counts as updated and the removed one as removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s fetched A clone that fetched the remote's snapshot chain failed the whole run when a git here could not join it to its own, or look for the snapshot once joined. Those are now a failed fetch, as before the redesign: the warning names the remote and git's words, and the generation is recorded afresh. A git that fails before the remote is asked still fails the run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e-push hook The script reads what git lists on stdin, to leave a push that carries the snapshot ref alone, and the block runs first, so the rest of an existing hook (git-lfs, git's own sample) read nothing. The call line now keeps a copy of stdin in the git directory, runs the script on it, hands the copy to the rest of the hook as its stdin and removes it. `command exec` and one `&&` chain keep a `sh -e` hook from exiting on a redirection error or a git missing from its PATH. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… ones included An upload asked git about the SDK's ignore rules only when git could also merge (2.38 or newer), so with an older git the files `.gitignore` keeps out, `node_modules` among them, went up. `ls-files` is as old as git; any git that is found now reads the rules, and only a directory without a `.git` of its own, or with no git at all, is zipped unasked. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he phase notes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… a public method `isRootOfRepository` and the `.git`-on-disk check it starts with are module functions taking the services and the directory, as the other helpers in the context do; `hasGitDirectory` and `isRepository` are the shell over them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`eslint .`, which `pnpm test` runs after the suite, refused the unused import on CI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Shield-Jaguar
approved these changes
Nov 3, 2026
…esting A merged file's markers are read through markerOf, which says which marker a line is, and a table of where each marker stands; the opened flag goes, and the label check splits on the colon instead of a regex that backtracks. A diff-tree record becomes a Change in changeOf, so the rename test is written once. The summary's removed and kept lists are filters over the inputs. The conflict choice a run takes without asking is a pure unaskedChoice, so execute asks only when the choice is open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hook rewrote a `help:<topic>` suggestion to the help command with the topic as its argument, then ran the suggestion as first spelt, so a confirmed "Did you mean help sdk?" ran the command `help:sdk`, which does not exist, and the hook asked the same question again. The rewritten command is what runs now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The publish command reads the SDK directory into a const before the action is chosen; the force flags are readonly statics; the share hook's two printf lines are raw templates; the test environment helper imports through node:; an unresolved file asks its markers through an optional chain; a line ending is swapped with replaceAll; and the test helpers hash the stage entries and read a snapshot's files at once instead of one after another. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Remove 72 tests, 4 of them folded into the test beside them, that another test already fails for: - conflict and repository-state unit tests, which merge-plan and GitContext.inspect already pin - GitContext merge, write and hook cases that the action tests, the GitService tests or the ShareHook unit tests assert, and one that only proved git's own merge=binary - action tests whose branch the action only hands down (the side chosen, the hook's kind, a failed fetch), and table rows that run the same code - trivial equality checks, a copy of the hook-call constant, and prompt tests pinning whole sentences of messages with no logic Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged
Contributor
|
🎉 This PR is included in version 2.2.0-beta.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Merged
Contributor
|
🎉 This PR is included in version 2.2.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Keep your edits when regenerating an SDK.
sdk generate --git-initmakes the SDK directory a git repository of its own and records each generation under a hidden ref; every later run merges the fresh generation over your changes with git's three-way merge, and asks how a conflict is settled. A clone fetches the snapshot it lacks, a pre-push hook shares the snapshots with every push, andsdk publishkeeps the edits too, uploading only what git would commit.A redesign of #445
This branch rebuilds the feature of #445 (
asadali214/custom-code-injection) fromdev, phase by phase, under the rules that PR's review set out (.ai/plans/custom-code-phases.md, section 2): functional core and imperative shell, no private instance methods, no middlemen,Promise<Result>with early returns, one context for the SDK directory under git. Nothing from #445 is reused as code; its commits were the reference for behaviour, wording and tests, and its design document is back as.ai/plans/custom-code.md, made true for this layout. #445 can be closed in favour of this one.What a developer can do, by phase
--api-versionand--zipare gone,--destinationis the directory--git-init, and have the first generation recordedBreaking changes
sdk generateno longer takes--api-versionor--zip, and--destinationnames the SDK directory itself rather than a parent that gets<language>below it.--forceon both SDK commands never asks: conflicts are resolved using the generator's changes, and an SDK that cannot be merged is replaced, with a warning.Reading it
.ai/plans/custom-code.md: the design, the decisions, the merge table, conflicts, the lab cases, the files and the architecture..ai/plans/custom-code-phases.md: how it was built, phase by phase, with the questions each phase raised and their answers.CONTEXT.md: the terms.sdk publish --destinationdefaults to<input>/sdk/<language>, andapi transform --forcediffers by a word) and belong to the docs repo.Verification
Each phase ended with tsc on
srcandtest, ESLint, Prettier on the touched files, its own suites and one full run; the last full run is 2295 passing, 79 pending. The behaviour tests drive the actions with a stubbed generation service over real git repositories, bare remotes and apre-receivethat refuses the ref included.🤖 Generated with Claude Code
https://claude.ai/code/session_013bJWNvjd8T6iWD7ztRYi3o