fluttersdk_artisan 0.0.14
fluttersdk_artisan: ^0.0.14 copied to clipboard
Composable Dart CLI framework and stdio MCP server for Flutter. Scaffolding, code generation, transactional plugin installs, hot reload, REPL, and AI agent tooling.
Changelog #
All notable changes to this project will be documented in this file.
This project follows Semantic Versioning 2.0.0. Entries follow the Keep a Changelog shape.
0.0.14 - 2026-09-01 #
Fixed #
-
injectEntitlementwrote a file Xcode never read. The op set the key inios/Runner/Runner.entitlementsand stopped, so unless somebody had already opened Xcode and added the capability by hand,CODE_SIGN_ENTITLEMENTSwas unset and the entitlement was inert. The op now also points the application target at that file. Concretely this is what made an installer-driven iOS push setup impossible: the plist was correct and the build ignored it. -
A
project.pbxprojthe reader declined to touch aborted the whole plugin install, half-applied.XcodeProjectEditorrefuses to write a project it cannot re-emit byte for byte, and that refusal arrived atInstallTransaction's dispatcher catch as anError, so the install stopped. Stopping undid nothing: the entitlements plist and every earlier helper-backed write (pubspec.yaml, the gradle files,Info.plist,.env) had already landed and none of them roll back, so the operator was left holding a partial install and an exception string. The trigger is ordinary rather than exotic. Xcode escapes non-ASCII in that file as\Uxxxx, the parser resolves an escape it does not model to the bare character, and the re-emission then differs, so a project whosePRODUCT_NAMEis"Caf\U00e9"was enough to reach it. The refusal is now a warning beside the two cases that were already non-fatal (no.xcodeprojat all, and a target already signing with a different entitlements file): it names the value to set on the application target and says thatRunner/Runner.entitlementsstays inert until the setting carries it. Aproject.pbxprojthat is not a valid project file at all still aborts the install, because that is not the editor declining to write a project it read and it is not answered by editing a build setting by hand. The round-trip guard itself is unchanged; refusing to write a file it cannot reproduce exactly is what makes the editor safe to point at a real project. -
PlistWriterandPodfileEditorrefused to edit a file that did not exist yet, which is every Flutter project's entitlements file until somebody opens Xcode, and every Swift Package Manager project's Podfile always. Both now create the file first.PlistWritercreates ONLY an.entitlementspath: an absentInfo.pliststill throws, because that means the caller has the wrong path and inventing one would hide it. -
A created Podfile is now shaped for the platform it is created for.
PodfileEditor's creation path is reached fromsetPlatformVersion,addPostInstallHookandaddPodLine, and all three accept macOS, so the first cut wrote one iOS-shaped file for both:platform :ios, '12.0'andflutter_install_all_ios_pods. On a macOS project with no Podfile that produced a file nothing could build.setPlatformVersion(path, 'macos', v)matchesplatform :osx, missed the:iosline the creation had just written, and took its prepend branch, so the file ended up carrying TWOplatformdeclarations; andflutter_install_all_ios_podsis not defined by the macOS half of Flutter'spodhelper.rb, sopod installfailed with an undefined method. Creation now takes the platform from the caller and emitsplatform :osxwithflutter_install_all_macos_podsfor macOS, which is also what makes the followingsetPlatformVersionREPLACE that line instead of adding a second one. The mapping lives in one place now, so the created file and the regex that later edits it cannot disagree again. Worth stating plainly, because it is a regression this branch introduced and not an old bug: before create-if-absent, that same call threw and wrote nothing. This is a helper-backed write path the installer does not roll back, so a wrong file has no undo. -
A created Podfile called four Flutter helper functions and defined none of them, so
pod installfailed on both platforms. The file saidflutter_install_all_ios_pods(or the macOS one) and stopped there. Those are Ruby methods from Flutter'spackages/flutter_tools/bin/podhelper.rb, and a Podfile only has them after it requires that file, which it can only do once it knows where the Flutter SDK is. So the whole preamble Flutter'stemplates/cocoapods/Podfile-*carry is load-bearing rather than decoration: aflutter_rootfunction that readsFLUTTER_ROOTout of the generated xcconfig (ios/Flutter/Generated.xcconfigon iOS,macos/Flutter/ephemeral/Flutter-Generated.xcconfigon macOS, each raising a named error when it is absent becauseflutter pub gethas not run), therequireofpodhelperrelative to it, and theflutter_ios_podfile_setup/flutter_macos_podfile_setupcall. The created file now carries all of it, generated from Flutter 3.47's own two templates, along with the analytics opt-out, theproject 'Runner'build-configuration map and apost_installblock calling the platform'sflutter_additional_*_build_settings. It diverges from the templates in exactly two places, both deliberate: the iOSplatformline is written uncommented, becausesetPlatformVersionedits that line and a commented one would make the bump inert, and the nestedtarget 'RunnerTests'block is omitted, because CocoaPods aborts on a target name itsRunner.xcodeprojdoes not carry and a project without a Podfile need not have a test target. Worth stating plainly: before this, the created file could notpod installon EITHER platform. The macOS fix above corrected which helper was named; it was still a name nothing defined. -
addPodLineandaddPostInstallHookrefuse to create a Podfile when the caller does not name a platform. Both gained an optionalplatform:argument; without it an absent file throwsFileSystemExceptionexactly as it did before this branch. Neither method can infer the platform from its own arguments, and a Podfile is platform-shaped down to the podhelper function it calls, so guessing produces the broken file described above. Refusing to create one is the recoverable failure.setPlatformVersionalready takes the platform and creates as before. -
InjectPodfileLinecreates an absent Podfile again. The refusal above cost the op the capability it was given earlier on this branch:InstallTransactioncalledaddPodLinewithoutplatform:, so on a project whoseios/ormacos/directory holds no Podfile (every Swift Package Manager project, always) the op stopped withPodfile not foundinstead of writing one. It carriesop.platformand now forwards it, so both platforms get a file of the right shape. It is the onlyPodfileEditorcall site inlib/that mutates a file; the other reference,ConflictDetector, only computes the path. -
A created Podfile no longer declares a deployment target three major versions below the project's own. It said
platform :ios, '12.0', a constant with nothing behind it; the created file now declares iOS15.0and macOS12.0, the values Flutter 3.47's ownflutter createtemplates carry (templates/cocoapods/Podfile-iosandPodfile-macos), and iOS15.0is also theIPHONEOS_DEPLOYMENT_TARGETof the consumer this work was driven by. Reading the project's real target would beat any constant, but it lives inRunner.xcodeproj/project.pbxprojand the helper is handed a Podfile path only. Under-declaring is the harmful direction: CocoaPods then resolves pod versions older than the project can use.
Added #
XcodeProjectEditor.setEntitlementsPath(), a trivia-preserving OpenStep-plist reader and writer for.pbxproj, with one public setter and no general mutation API. Three properties are load-bearing rather than incidental. It scopes the write to thePBXNativeTargetwhoseproductTypeiscom.apple.product-type.application, reached through itsbuildConfigurationList: a stock Flutter project holds nineXCBuildConfigurationblocks and only three are the app's, so a naive sweep would put a signing entitlement on the test bundle and the project defaults. It parses, re-emits and compares byte for byte before touching disk, and refuses rather than writing a partially-edited project, because a truncated.pbxprojcannot be opened and the installer's helper-backed ops do not roll back. And it never REPOINTS a configuration that already names a different entitlements file: every macOS Flutter project carriesRunner/DebugProfile.entitlementsandRunner/Release.entitlements, and silently overwriting those would drop the sandbox grants they hold.
Documentation #
- The installer DSL page described the smaller half of what
injectEntitlementdoes. Its table said the op sets a key inRunner.entitlementsand said nothing about theCODE_SIGN_ENTITLEMENTSwrite into<platform>/Runner.xcodeproj/project.pbxproj, a second file the transaction cannot roll back, nor about the three cases that warn and skip it (no.xcodeproj, a target already signing with another entitlements file, which is every macOS project, and a project the.pbxprojreader refuses). A plugin author reading that page would stage the op without knowing which files it touches, which is the same under-reporting the dry-run preview exists to prevent. - The caret constraint quoted by
doc/getting-started/installation.md,doc/commands/install.mdanddoc/plugins/authoring.mdnow tracks this release. One of them still said^0.0.4, which tells a reader the package never moved.
0.0.13 - 2026-08-20 #
Documentation #
-
The docs still told readers the session is one global file, in ten files. 0.0.10 moved the session to
~/.artisan/sessions/<hash>/and the code changed with it, but the skill, the MCP tool descriptions and the command pages kept describing~/.artisan/state.jsonas the place the running app is recorded. This is not cosmetic: a field report has an agent concluding it had corrupted a sibling's session, because the tooling it was reading said there was only one slot to corrupt. It had reasoned correctly from a document that was wrong.Swept
skills/fluttersdk-artisan/SKILL.md(including law 2, which stated the old model as a rule),references/mcp-tools.md,references/tinker-eval.md,references/state-and-recovery.md,doc/mcp/{overview,setup,tool-reference}.md,doc/getting-started/quickstart.md,doc/commands/{index,start,tinker,mcp-serve}.md. The mentions that remain are the ones that are still true: the legacy pointer exists, is read as a fallback, and is what the hand-written recovery recipe targets.Also corrected while there: the recovery for "no app detected" said to remove
~/.artisan/state.json, which clears the pointer and not the session; the troubleshooting table quoted two error strings that no longer exist; and neither carried thebooting: truepath. Skill version 0.0.5 -> 0.0.6.
0.0.12 - 2026-08-20 #
Fixed #
-
A
startinterrupted by its caller left the app running and unrecorded. The session was written only after the VM Service URI was scraped, which is the last and longest thingstartwaits for. An MCP client kills a tool call at 60s and an iOS build routinely takes longer, so the app came up, the process survived (the wrapper detaches it), and nothing recorded it:statusansweredrunning: falseabout an app that was listening on 8183,stophad no pid to reap, and the operator had to hand-write the state file. Reported from the field ondevice=50BAD9FA-....The session is now written as soon as the child PIDs are known, carrying the pid, the FIFO, the ports and the device, with
vmServiceUri: nullandbooting: truesaying the record is incomplete rather than wrong. The URI is filled in when the scrape lands. And a connected command that finds no URI reads the last one out of the session log and keeps it, so an interrupted start heals on the next call instead of needing a hand-written file. -
statusreported an unfinished start as a healthy session. That is where the reported symptom chain started: the operator readrunning: falseabout an app that was serving, concluded the start had failed, and went looking for a recovery. It now reportsbooting: truealongside the record and says in plain words that the URI is not there yet and how it gets filled in. -
artisan_start's MCP description still told agents the state was one global file. "writes ... to~/.artisan/state.json" and "ONLY ONE Flutter app per machine can be tracked at a time (single-slot state)" have both been false since 0.0.10, and an agent acting on them concluded it had corrupted a sibling's session when the two were never sharing one. The description now says sessions are per project, and adds the line the timeout above needed: if the call times out the app is probably still starting, callartisan_status, and do not hand-write the state file. Same correction in the skill reference, whose recovery section also carried the old error text. -
The documented state schema omitted
stdinPipe, so following it produced a file that could not hot restart. The docblock is the recipe an operator reaches for precisely whenstarthas failed them, and it listed eleven keys without the onereloadandhot-restartneed. Both now documentstdinPipe,stdinHolderPidandbooting. -
reloadandhot-restartblamed an old artisan for a missingstdinPipe. "the app was started by an older artisan that pre-dates the FIFO refactor" is one cause; a state file hand-written from the schema above is the likelier one now, and the message said nothing about what the key holds. Both messages name both causes and describe the value.
0.0.11 - 2026-08-20 #
Fixed #
-
The per-project session was keyed on the working directory, not the project, so the isolation only held for callers standing in the repo root.
sessionOwnershipErrordeliberately blesses running frombackend/or a package subdirectory, butsessionPathForhashed the cwd: a command from there missed its own session file, fell back to the shared~/.artisan/state.jsonpointer, and with two apps up was then refused for driving somebody else's. A false refusal, and the defeat of the very isolation 0.0.10 shipped.StateFile.projectRootFornow walks up to the nearest ancestor holding apubspec.yaml. Nearest rather than outermost, because that is the unitartisan startboots: two packages in one repository are two apps and want two sessions. No pubspec anywhere up the chain falls back to the directory itself, so non-Dart callers and the hand-written recovery recipe do not all land in one shared session.startrecords the same walked root inprojectRoot, since a raw cwd there would make a start from a subdirectory record a root the ownership check then measures every later command against.
0.0.10 - 2026-08-20 #
Added #
-
artisan startnow records its session per project, so two apps can be driven at once.~/.artisan/state.jsonwas one global slot: a second project'sstartsilently took it, and every connected command from the first project then drove the second app, succeeding each time. The measured case had a worktree in another repository rewrite the file mid-session, and two commands later produced a screenshot of an entirely different product. The session is now a DIRECTORY at~/.artisan/sessions/<sha256(projectRoot)[0:12]>/, holdingstate.json, the log and the FIFO, because those last two collided for exactly the same reason and fixing one member of the set is not fixing the set. Keyed by a digest rather than a slugified path, since a project path can contain separators, spaces and non-ASCII.~/.artisan/state.jsonis still written, as a pointer to whichever session started last. That is an interop contract, not a shim: hand-writing that file is the documented recovery recipe whenstartcannot boot an app, andread()falls back to it when this project has no session of its own. Two projects still need distinct--port,--vm-service-portand--cdp-port;startfails fast when one is taken. -
--state=<path>andARTISAN_STATE_FILEname the session explicitly. A global flag consumed before dispatch rather than a per-command option, because every connected command needs it and none of them owns it. The flag wins over the environment variable, and naming a session also bypasses the ownership check below, since the caller has already answered the question it asks. -
statusreportsownedByThisProjectandprojectRoot. It reads rather than acts, so it surfaces a foreign session instead of refusing it, but it must not present one as this project's: an agent readingrunning: truefrom a checkout with nothing up would conclude its own app is live and act on that. -
sessionOwnershipErrorrefuses a command that would act on another project's app. The legacy pointer describes whichever app started last, so astoprun from a project with nothing up reached across and killed a sibling's running app while reporting a clean success, and a connected command dialled the wrong VM Service. Wired into the connected-mode boot path,stop,reloadandhot-restart. A working directory INSIDE the project passes, state with no recordedprojectRootpasses (hand-written recovery state usually omits it), and an explicit--statepasses.
Fixed #
-
--state=<path>never reached the command when a consumer wrapper owned the invocation. The flag is consumed before dispatch into a static on this isolate, but a wrapper delegation spawns a SEPARATE Dart process, and the stripped args carried nothing across.artisan stop --state=/xfrom any project with abin/dispatcher.darttherefore acted on the auto-resolved session instead, silently: the exact failure the flag exists to prevent, on the exact path most consumers take. The flag is now re-attached to the delegated args.ARTISAN_STATE_FILEwas unaffected, since the child inherits the environment. -
The ownership check ignored
ARTISAN_STATE_FILE, so it refused the session that variable had just pointed it at.StateFile.pathhonours the flag and the environment variable equally, and the two are documented as equivalent, but all five guard call sites read the flag alone. With the variable set and no flag, artisan read the named foreign session and then declined to drive it.StateFile.explicitPath()now answers "did the caller name a session", by either spelling. -
artisan restartignoredstop's refusal and relaunched on the other project's settings.restartisCommandBoot.none, so the connected-mode guard never runs, and it discardedStopCommand.handle's exit code. Run from project A while the pointer described B: stop printed the refusal and returned 1, restart carried on, andsessionOverridesFromcarried B's web port, VM Service port, CDP port and device into A's relaunch, reproducing theAddress already in usethis same release set out to fix. A non-zero stop now aborts the restart. -
Two
artisan startruns raced on the shared pointer's staging file. Every write goes through.tmp+ rename, but the legacy pointer is one path for every project, so both processes staged~/.artisan/state.json.tmpand the loser's rename threwFileSystemExceptionafterflutter runhad already spawned, leaving an orphan with no state file to find it by. The staging name now carries the pid. The per-project session files were never affected; the mirror re-introduced the shared slot they exist to remove. -
StateFile.deletecompared paths exactly and deleted an unattributed pointer. Astopfrom a subdirectory has no session file of its own, so the fallback root is that subdirectory and the pointer was left behind, still advertising a session that had just been stopped; the check now uses the same is-within rule assessionOwnershipError. A pointer with no recordedprojectRootis now left alone: that is the hand-written recovery file the read path goes out of its way to honour, and deleting it took away the escape hatch people reach for precisely whenstarthas already failed them. -
artisan restartrelaunched on the default web port, VM Service port and device, keeping only the CDP port. Several worktrees run their own dev server at once, so 3100 is usually held by a sibling: the stop half succeeded, the relaunch failed withAddress already in use, and the app being driven was simply gone. The error names a port that appears in no command the caller ran, so it reads as a machine problem rather than a missing flag. A restart onto the default web-server device is quieter and worse: nothing renders, and every screenshot after it comes back byte-identical.restartnow carries the device and all three ports across, and declares each as a flag so an explicit value still wins. -
logslooked forflutter-dev.logbeside the state file, which now resolves inside the session directory. It reads the session log and falls back to the shared~/.artisan/flutter-dev.logfor an app started by an older artisan. -
artisan_start's tool description pointed agents at a tool that does not exist. Two lines of the description listedtinker_evalamong the tools that read~/.artisan/state.json; the tool isartisan_tinker. An agent taking the description at its word calls a name the server never registered and gets an unknown-tool error. Also corrected the same stale name inMcpToolDescriptor's own docstring example. (lib/src/mcp/mcp_server.dart,lib/src/mcp/mcp_tool_descriptor.dart,skills/fluttersdk-artisan/references/mcp-tools.md,skills/fluttersdk-artisan/references/tinker-eval.md) -
The MCP server reported itself as
0.0.8to every client. TheImplementation.versionstring inMcpServercarries the comment "Keep in sync with pubspec.yamlversion:on each release cut" and was missed on the 0.0.9 cut, so an MCP client's initialize handshake read a version one release behind while the package on disk was 0.0.9. Client-side version gating and bug reports both keyed on the wrong number. (lib/src/mcp/mcp_server.dart)
Documentation #
- The registry dispatch fires on a published release now, not on every push that touches the skill. Under the push trigger
fluttersdk/aiclimbed to v1.3.75, and most of those releases re-published identical skill content: a docs commit and a release commit each cost the registry a version. The registry version now tracks published artisan releases instead of counting commits.workflow_dispatchstays as the manual escape hatch when a skill fix has to reach users before the next release. (.github/workflows/dispatch-to-registry.yml) - The skill undercounted the builtin commands by one and the CLI-only set with it.
make:fast-clilanded in 0.0.2 and the counts were never rolled forward, so the skill promised "21 builtin CLI commands" and "11 CLI-only" against a real 22 and 12. Corrected in the frontmatter description, Core Law 6, the section 14 reference table, and thereferences/cli-commands.mdtitle, intro, andartisan_listheading. Core Law 6's inline list was 11 items long against its own count of 12:mcp:uninstallwas missing, and it is now in both the list and the exclusion-reason table. (skills/fluttersdk-artisan/SKILL.md,skills/fluttersdk-artisan/references/cli-commands.md) start --timeout=<seconds>was undiscoverable from the skill. 0.0.9 made the VM Service URI scrape window configurable, but the skill still presented 90s as a fixed property ofstartin three places, so an agent facing a slow cold boot had no documented way out and would conclude the boot was broken. The MCP schema exposes no timeout parameter, which makes this a CLI-only escape hatch worth naming explicitly. (skills/fluttersdk-artisan/SKILL.md,skills/fluttersdk-artisan/references/mcp-tools.md,skills/fluttersdk-artisan/references/state-and-recovery.md)- The skill cited the substrate allowlist as
mcp_server.dart:871-882; the range had drifted by two lines. Both citations now name the_safeArtisanCommandNamesconstant instead, which does not rot when the file moves. (skills/fluttersdk-artisan/SKILL.md,skills/fluttersdk-artisan/references/mcp-tools.md) - Skill version 0.0.4, stamped against package 0.0.9. The previous stamp claimed 0.0.7, which predates the
--timeoutflag and therestartCDP-port preservation the skill now describes. (skills/fluttersdk-artisan/SKILL.md)
0.0.9 - 2026-07-29 #
Added #
start --timeout=<n>option (default90): configures the maximum seconds the VM Service URI scrape loop waits forflutter runto print the debug URI in the log file. Previously the deadline was hardcoded to 90 s; cold starts on slow CI machines or after a fresh Flutter SDK install can exceed this limit. Setting--timeout=120(or higher) prevents false-timeout failures. The error message now reports the configured value rather than a literal "90s". Applies to the--cdp-portbranch only; the non-CDP branch retains its own hardcoded deadline.start --cdp-port=<n>now probes the CDP port for availability BEFORE launching Chrome. When the port is already in use, the command exits 1 immediately with a clear message: "CDP port N is already in use; pass --cdp-port
Changed #
plugin:install'sinstall.yamlbootstrap_commandnow AUTO-RUNS after a successful manifest install instead of only printing a hint. Once the plugin is registered (plugins.json+lib/app/_plugins.g.dartregenerated), the declared command is spawned as a fresh dispatcher subprocess (./bin/fsa <cmd> --non-interactivewhenbin/fsaexists, elsedart run <consumer>:artisan <cmd> --non-interactive), so the just-registered plugin command actually executes.--non-interactiveis always forwarded so an interactive bootstrap (e.g.starter:install) cannot hang.--bootstrap-command=<name>overrides the manifest value;--no-bootstrapskips the auto-run and falls back to the hint. When no dispatcher resolves (nobin/fsa, no consumer pubspec name), the one-lineBootstrap with: artisan <cmd>hint is printed as before.
Fixed #
plugin:installnow surfaces a failingbootstrap_commandinstead of implying success.BootstrapCommandRunner.runreturns aBootstrapRunResultcarrying the subprocess exit code and captured stderr (it previously discarded theProcessResult), andplugin:installwarns with the exit code + stderr and prints the manual bootstrap hint when the chained command exits non-zero. A bootstrap that fails (stale fast-CLI bundle, unknown command, scaffold error) is no longer reported to the operator as if it had completed.start --timeout=<n>now rejects zero and negative values immediately with an actionable error ("--timeout must be a positive integer"), instead of silently passing a non-positive deadline to the VM Service scrape loop and producing a confusing "Timed out after 0s" failure.- Passing an unknown option to any command now fails loudly instead of silently printing help and exiting as if help were requested (issue #12). The dispatcher writes
Unknown option: <flag>to stderr (both long--fooand short-xforms), prints the command help, and exits non-zero. Other parse failures keep their original messages: a missing option value (Missing argument for "..."), a disallowed value, and a value given to a flag each surface their specific diagnostic unchanged.--help/-hand every valid invocation are unaffected. Because this is the shared dispatch path for every command, the fix benefits every plugin CLI built on the substrate.
0.0.8 - 2026-06-16 #
Fixed #
restartnow preserves the--cdp-portvalue from the previous session. Previously,restartran stop then start, but stop deletedstate.jsonbefore start could read the prior CDP port, silently dropping the Chrome remote-debugging setup.RestartCommandnow readscdpPortfrom state before stopping and forwards it intoStartCommand.RestartCommandalso declares the--cdp-portoption, so an explicit--cdp-porton therestartinvocation parses and wins over the forwarded value.ManifestInstallernow imports a published config factory from its consumer-relativelib/config/<name>.dartpath instead of the plugin package barrel, so the injected() => <name>Configreference resolves after aplugin:installthat publishes a config file.
Documentation #
doc/commands/start.mdnow documents the--cdp-portoption: synopsis, options table, thestate.jsonschema (cdpPort/chromePid/tmpProfileDir), and a CDP example. The stale "Reserved for D6, always null in V1" field notes are corrected.- Fixed 14 broken internal links across
doc/commands/*anddoc/plugins/*(dead deep-dive page links repointed to the command index), and synced therestartCDP-port behavior intodoc/commands/index.mdand thestate-and-recoveryskill reference.
0.0.7 - 2026-06-09 #
Added #
start --cdp-portnow fails fast with a clear, actionable error when the web port is already bound, instead of timing out after 90s. The error message names the busy port and suggests runningfsa stopor selecting a different port via--port(issue #25).
Fixed #
start --cdp-portnow reaps the spawned Chrome process, flutter web-server, FIFO pipe, and temporary profile directory when launch fails after the port probe (issue #25). Previously, failed CDP sessions could leave orphaned processes and lingering files. Cleanup is best-effort; cleanup failures are ignored and never mask the original error.
0.0.6 - 2026-05-28 #
Added #
mcp:install --invocation=<exec>option for plugin-aware.mcp.jsonfallback whenbin/fsais absent (writesdart run <exec> mcp:serve). Whitespace-only values are trimmed and treated as not provided, so--invocation=" "falls back to the:dispatchershape rather than producing an invaliddart run mcp:serveentry. Plugin wrappers (fluttersdk_dusk,fluttersdk_telescope) can now inject--invocation=<plugin>automatically so substrate-only consumers withoutbin/fsaget the correct MCP wiring.skills/fluttersdk-artisan/SKILL.mdSection 8 (Community: star + issue, optional, once per session) plus a newskills/fluttersdk-artisan/references/community.md(156 lines). Trigger split: star fires after a task verified end-to-end against the running app or a cleanmake:*/plugin:install/mcp:installflow; issue fires only on a genuine artisan-side defect (malformedartisan_*JSON, substrate-allowlist registration failure,.mcp.jsonprecedence broken,artisan_tinkercrash on a valid expression, AOT staleness regression, hot-reload semantics inverted). Section 5 substrings (No Flutter app detected,Pipe missing,Expression compilation error,Isolate sentinel,mkfifo failed (Windows ...), etc.) are explicitly excluded from the issue trigger because they are state / environment / expression-shape signals, not bugs. Both CTAs are prose-permission, never auto-executed, gated oncommand -v gh && gh auth status, URL-only fallback whenghis missing, and capped at one shot per session.gh issue createuses--label bugalone; theagent-reportedlabel is not provisioned on the artisan repo yet, so the example deliberately omits it to avoid pre-creating labels on the user's account.
Changed #
mcp:installnow writes.mcp.jsonatomically via the.tmp+ rename pattern (mirrorsStateFile.writeandPluginsRegistryFile.write), so concurrent MCP clients (Claude Code, Cursor, Windsurf) never observe a half-written file when the command is interrupted mid-write.- Repo flow: adopted GitHub Flow (single long-lived
master; retired thedevelopaccumulator and all merged feature branches).CLAUDE.mdnow carries this as Golden Rule 7 plus a## Branchingsection documenting task-branch naming (<type>/<kebab-case-topic>), the squash-vs-rebase-vs-merge decision, the release shape (release/X.Y.ZPR bumps pubspec + promotes CHANGELOG, then tag fires.github/workflows/publish.yml), and the external-contributor fork-and-PR shape.delete_branch_on_merge: trueenabled on origin so merged branches auto-cleanup.
Fixed #
artisan_tinkerMCP tool description and theevalinput-schema example expression scrubbed of consumer-specific class names (MonitorController.instance.refresh(),User.current.name). Replaced with framework-neutral examples (WidgetsBinding.instance.lifecycleState,MyService.instance.refresh()) and tightened the scope language ("controllers, models, framework facades" -> "top-level functions, singletons, services"). Surfaces to every MCP client (Claude Code, Cursor, Windsurf, etc.) ontools/list, so the published 0.0.x docs no longer leak private consumer-app identifiers.- MCP
serverInfo.versionno longer drifts (continued from 0.0.5 NIT 7): the hardcodedversion: '0.0.5'literal inlib/src/mcp/mcp_server.dartis manually synced to'0.0.6'as part of this release-cut commit. A future patch may switch to a build-time constant to eliminate manual drift recurrence (still deferred per scope).
0.0.5 - 2026-05-23 #
Fixed #
./bin/fsaAOT bundle staleness missedlib/app/_plugins.g.dartmtime (issue #9 GAP A): afterplugin:installregeneratedlib/app/_plugins.g.dart, subsequent./bin/fsainvocations kept running the stale cached bundle, so newly registered plugin commands silently did not surface. Fixed by two complementary changes: (a) appended condition-5 (_plugins.g.dart -nt STAMP_FILE) tobin_fsa.sh.stub'sneeds_build()shell function so the shim self-heals on any plugin operation regardless of who mutated the file, and (b) addedCliBundleCache.purge(projectRoot)to the legacyplugin:installsuccess path,plugin:uninstallsuccess path, andplugins:refreshsuccess path so the cache invalidates as a direct side effect of artisan-managed plugin lifecycle events. Manifest-flowplugin:installdelegates toplugins:refreshtransitively, so a single purge call covers both. Migration: re-runmake:fast-cli --forceto pick up the new shim. No CI or publish changes.- MCP
dusk_evaluatereturned a sentinel string instead of evaluating (issue #9 GAP F): the host-sideext.dusk.evaluatehandler influttersdk_duskreturns a no-op sentinel by design; the actual evaluation must run throughvm.evaluate.lib/src/mcp/mcp_server.dart_dispatchnow special-casesdusk_evaluateby tool name and routes throughVmServiceClient.evaluate(isolateId, expression)directly, with 3-branch error handling per the VM Service spec (InstanceRefhappy path;ErrorRefruntime exception surfaced asisError: true;Sentinelstale-isolate with actionable hint;RPCErrorcode 113 compile error with details extracted). Coordinated bump pairing:fluttersdk_dusk0.0.2 plans to bump the artisan constraint to^0.0.5. - MCP
serverInfo.versionno longer drifts (issue #9 NIT 7): the hardcodedversion: '0.0.1'inlib/src/mcp/mcp_server.dartlagged the pubspec across four releases. This release manually syncs the literal to'0.0.5'as part of the release-cut commit. A future patch may switch to a build-time_kArtisanVersionconstant to eliminate manual drift recurrence (deferred per scope). TheserverInfo.nameliteralfluttersdk_artisan_mcpstays as-is; the MCP spec treatsserverInfo.nameas a display hint, and Claude Code derives tool prefixes from the.mcp.jsonkey, not from the server-advertised name. Seedoc/mcp/setup.md#server-identityfor the rationale.
0.0.4 - 2026-05-21 #
Fixed #
- MCP server returns empty tools/list (issue #7, Bug A):
dispatcher.dart.stubnow forwardscollectMcpTools: args.isNotEmpty && args.first == 'mcp:serve'torunArtisan, so plugin providers'mcpTools()collect into the registry when consumers invoke./bin/fsa mcp:serve. Migration: substrate-installed consumers re-rundart run fluttersdk_artisan install --forceto regeneratebin/dispatcher.dart. Magic-installed consumers need a paired magic-side stub update (tracked separately) beforemagic:artisan install --forcepropagates the fix. mcp:installwrites the canonical post-install entry shape (issue #7, Bug B):.mcp.jsonentry branches between./bin/fsa mcp:serve(POSIX withbin/fsapresent) anddart run :dispatcher mcp:serve(Windows or no-fsa fallback); the previous hardcodeddart run fluttersdk_artisan:mcpshape routed through the substrate standalone, which never loads consumer plugin providers. Migration: consumers must re-run./bin/fsa mcp:install(ordart run fluttersdk_artisan mcp:install).- Auto-delegation now resolves the canonical consumer wrapper:
_defaultDelegateatlib/src/console/run_artisan.dartpreviously emitteddart run :artisan, which resolves only tobin/artisan.dart. Post-0.0.2 the canonical wrapper isbin/dispatcher.dart. Fixed by prepending:dispatcherupstream of the delegate call ((delegate ?? _defaultDelegate)([':dispatcher', ...args]));_defaultDelegate's body simplifies to['run', ...args]. Latent since 0.0.2; not caught by tests because the existing delegation tests mockeddelegate:without asserting on the prefixed args. doctoradvisory extended for pre-Bug-B.mcp.json:doctornow advisory-warns when.mcp.jsonstill containsfluttersdk_artisan:mcpargs, pointing the user at./bin/fsa mcp:installto upgrade. Does not affect exit code../bin/fsarebuilt AOT bundle on every invocation: the staleness check comparedpubspec.yamlmtime againstpubspec.lock.dart pub addupdatespubspec.yamlafter pub get writes the lock, leavingpubspec.yamlmtime newer thanpubspec.lockfor every freshly installed consumer; that tripped the check on every call. Compare against the build stamp file instead (written at the end of every successful compile), sopubspec.yamlnewer than the stamp means the user actually edited it. Cached invocations now hit the ~50ms target. Discovered during A-Z e2e testing.make:commandcrashed with "Stub file not found: artisan_command.stub": the stub asset never shipped in the publish archive even thoughMakeCommandCommand.getStub()declared it as the canonical scaffold name. Added the missingassets/stubs/artisan_command.stubwith the canonicalfinal class ... extends ArtisanCommandshape honoring{{ className }}/{{ namespace }}/{{ commandName }}placeholders. Discovered during A-Z e2e testing.
0.0.3 - 2026-05-21 #
Changed #
xmlconstraint downgraded^7.0.0->^6.5.0(pubspec.yaml): pub.dev resolution now intersects withimage ^4.0.0(used byfluttersdk_dusk'sext_screenshot.dartviaxml ^6.0.1). The 0.0.2 cut pinnedxml ^7.0.0, which madefluttersdk_duskunresolvable as a hosted dep alongsidefluttersdk_artisan 0.0.2because noimage 5.xexists to satisfy the upper bound. Reverted the 8XmlName.parts('localname')migration sites inlib/src/helpers/plist_writer.dartback toXmlName('localname')so the file compiles cleanly against xml 6.x (where.partsdid not yet exist). xml 7 migration is deferred untilimageships a release on the xml 7 line.
0.0.2 - 2026-05-20 #
Breaking #
consumer:scaffoldrenamed toinstall: the commandconsumer:scaffoldno longer exists. Consumers must usedart run fluttersdk_artisan installgoing forward.bin/artisan.dartrenamed tobin/dispatcher.dart: the scaffold output path has changed. Migration: re-rundart run fluttersdk_artisan install --forceto scaffold the new file layout, then update any scripts or CI steps that referencebin/artisan.dart.- Old stub removed:
consumer_artisan_bin.dart.stubis gone; the replacement stub isdispatcher.dart.stub. InstallCommand->InstallArtisanCommand: the public class on thepackage:fluttersdk_artisan/artisan.dartbarrel now carries theArtisanprefix so plugins exporting their ownInstallCommand(notifications, deeplink, etc.) no longer collide with the substrate at import time.
Added #
installauto-chainsmake:fast-cli: after writing the consumer entry and barrels,installautomatically runsmake:fast-clisobin/fsa(the AOT-compiled fast startup wrapper) is ready without a separate manual step.- New stub
dispatcher.dart.stub: replaces the formerconsumer_artisan_bin.dart.stub; rendered tobin/dispatcher.dartduringinstall. artisan start --cdp-port=Nopt-in flag (lib/src/commands/start_command.dart): when set, pre-launches Chrome with--remote-debugging-port=N --remote-allow-origins=* --user-data-dir=/tmp/dusk-chrome-N, runsflutter run -d web-server --web-port=N --web-experimental-hot-reload --host-vmservice-port=N(silent remap from--device=chrome), waits for the "is being served at" log line, navigates Chrome to the served URL via inline CDP, then scrapesvmServiceUrifrom the DWDS log once the debugger client connected. WriteschromePid+cdpPort+tmpProfileDirto~/.artisan/state.json. Default flow (no--cdp-port) unchanged. Gates the branch onflutter --version --machine>= 3.30.0 with an actionable upgrade error.artisan stopChrome cleanup: whenstate['chromePid'] != null, sends SIGTERM, waits the grace period, escalates to SIGKILL if the process is still alive, deletestmpProfileDir. Inlines the SIGTERM-grace-SIGKILL pattern fromfluttersdk_dusk/lib/src/utils/chrome_reaper.dart:216-264to avoid inverting the plugin dependency direction (see Deferred Ideas: V1.x consolidation).artisan doctorFlutter SDK gate: new checkflutter sdk >= 3.30.0 (for --cdp-port)registered in the existing_Checklist. Advisory_cdpUpgradeWarningwriteln (mirrors_checkStaleMcpJsonpattern) surfaces an upgrade message when the SDK is too old. Required forflutter/flutter#170612(DWDS WebSocket hot reload on-d web-server).StateFileschema: newcdpPortfield (int | null, --cdp-port value passed to start; null when CDP not enabled). Roundtrip test added.- GitHub Release auto-creation in
publish.yml: newgithub-releasejob (depends on the OIDCpublishjob) extracts the## [<version>] - <date>block fromCHANGELOG.mdviaawkand creates a matching GitHub Release usingsoftprops/action-gh-release@v2. Falls back to a stub body linking toCHANGELOG.mdwhen the section is missing. make:fast-clibuiltin command +bin/fsawrapper (lib/src/commands/make_fast_cli_command.dart,assets/stubs/bin_fsa.sh.stub): scaffold a POSIX shell wrapper that compilesbin/dispatcher.dartinto an AOT binary viadart build cli, cached at.artisan/cli-bundle/bundle/bin/dispatcher. Wrapper auto-detects staleness (pubspec.lock SHA256 + Dart SDK version + pubspec.yaml mtime greater than pubspec.lock) and re-compiles transparently. Result: ~50ms startup for./bin/fsa <cmd>vs ~3s fordart run fluttersdk_artisan <cmd>(no "Running build hooks..." overhead). Idempotent on re-run;--forceoverwrites the wrapper. POSIX-only V1 (macOS + Linux); Windows .cmd variant deferred. The existingdart run fluttersdk_artisanpath is unchanged and remains the canonical CLI entry.
Changed #
plugin:installpreflight scope: the wrapper-presence check (bin/artisan.dartmust exist) moved out of the shared preflight into the legacy-injection branch only. Canonical-scaffold projects (lib/app/_plugins.g.dartpresent) now route through.artisan/plugins.jsonregistration without tripping the legacy gate, even when the consumer never wrote abin/artisan.dartfile.artisan start --vm-service-portnow plumbs through toflutter runas--host-vmservice-port=Nand is recorded instate.jsonso downstream tools see the actual bound port. The option was declared but never read in 0.0.1.publish.ymltriggers narrowed topush.tagsandworkflow_dispatch. Removed therelease.types: [published]trigger to avoid release/publish recursion (the workflow creates the release itself now). Tag-first flow:git tag X.Y.Z && git push origin X.Y.Z-> validate -> pub.dev publish via OIDC -> GitHub Release with CHANGELOG-driven notes.
Fixed #
start --cdp-portordering deadlock: 0.0.1 scraped the VM Service URI before navigating Chrome, which deadlocked under DWDS (the URI emits only after a debugger client connects). Restructured the branch to wait for the "is being served at" log line, navigate Chrome to the served URL, then scrape. Three end-to-end Chrome / CDP automation issues fixed alongside:--no-first-run+--no-default-browser-checkon the launch argv,Page.navigatenow targets the page-level WebSocket from/jsoninstead of the browser-level/json/version, automation-noise suppression flags added.start --cdp-port=<non-int>now returns exit 1 with an actionable error instead of silently falling through to the non-CDP path.stopno longer emitsChrome SIGTERM sent...unconditionally: the boolean fromProcess.killPidis checked and anot deliveredwarning surfaces when the signal could not land (process already gone, permission denied).doctorSDK gate now tolerates beta channel strings like3.30.0-1.0.preand missing trailing segments (3.30), matchingStartCommand.compareSemverexactly so the doctor cannot flag a version the start command would accept.- VM Service retries once on the transient DWDS
WipError: Promise was collectedand on the stale-isolate sentinel fromcallServiceExtension, so a single device-target switch or DWDS hiccup does not surface to consumers. installconsumer-wrapper detection accepts bothbin/dispatcher.dart(canonical post-rename) andbin/artisan.dart(legacy) as valid wrappers for auto-delegation.InstallArtisanCommand.scaffoldIntoauto-triggersPluginsRefreshCommandin-process when<root>/.artisan/plugins.jsonexists so the codegen barrel does not get overwritten with an empty list.bin/fsaPID-aware lock recovery: when a prior./bin/fsainvocation crashed mid-build the wrapper used to deadlock on.artisan/.fsa.lockfor every subsequent run. The stub now reads the holder PID, verifies the process is still alive, and reclaims the lock when it is not.
Known limitations #
- MCP schema drift for
artisan_start: the hand-authored_commandInputSchema('start')atlib/src/mcp/mcp_server.dartdoes NOT advertise the new--cdp-portflag. The substrate dispatch still routes CLI args through correctly, but agents drivingartisan_startvia MCP cannot discover the flag from the schema. V1.x backlog: auto-derive the schema fromArtisanCommand.signature/configure(ArgParser)so it cannot drift.
0.0.1 - 2026-05-19 #
Initial public release of fluttersdk_artisan. Pure Dart 3.4+ CLI framework and stdio MCP server for Flutter and Dart projects. Pana score 160 / 160 on first publish.
Commands #
21 builtin commands across 6 groups:
- Lifecycle:
start [--device],stop,restart,status,logs [--follow],reload,hot-restart. - Scaffolding:
consumer:scaffold(canonical wrapper for plain Flutter),make:plugin <name>(plugin package skeleton with workspace enrollment + magic-mode upgrade detection),make:command <Name>(context-aware command scaffold for plugin or consumer). - Plugin management:
plugin:install <name>(manifest-driven, scaffold-aware, or legacy injection),plugin:uninstall <name>,plugins:refresh,commands:refresh. - MCP:
mcp:serve(stdio JSON-RPC server with three-layer filter),mcp:install(writes.mcp.jsonentry, idempotent),mcp:uninstall. - Introspection:
doctor(preflight checks),list(all registered commands grouped by:namespace),help <cmd>. - REPL:
tinker [--eval=<expr>](VM Service evaluate against the running Flutter app; interactive mode falls back when--evalis absent).
Stdio MCP server #
- Built on
dart_mcp ^0.5.1. Entry point:dart run fluttersdk_artisan:mcp. - 10 substrate tools (always-on) surface artisan's own CLI as MCP tools so an LLM agent can bootstrap a Flutter app without leaving the chat: lifecycle quartet (
artisan_start/artisan_stop/artisan_restart/artisan_reload/artisan_hot_restart) plusartisan_status,artisan_logs,artisan_doctor,artisan_list,artisan_tinker. - Plugin tools register via
ArtisanServiceProvider.mcpTools(). The MCP server collects them at startup;ArtisanMcpToolCollisionExceptionattributes name clashes to specific providers. - Three-layer Cargo-style filter:
.artisan/mcp.json(file) +ARTISAN_MCP_TOOLS_*/ARTISAN_MCP_PACKAGES_*(env) +--include-tool/--exclude-tool/--include-package/--exclude-packageCLI flags. Allow uses first-non-null; deny is the union; deny wins everywhere. - Soft-fail at initialize when no Flutter app is running; lazy-reconnects to VM Service on the next tool call. Tool calls without a running app return an actionable
CallToolResult(isError: true)so the model can self-correct.
Plugin protocol #
- Declarative
install.yamlmanifest:publish,magic.provider,magic.configFactory,magic.routes,native.android(permissions / metaData / gradle plugins / dependencies),native.ios/native.macos(plistEntries / podEntries),native.web(headInjections / metaTags),env,prompts,placeholders,bootstrap_command. - Procedural escape hatch: subclass
ArtisanInstallCommandand drivePluginInstallerfor plugins that need runtime branching the schema cannot express.
PluginInstaller DSL #
Fluent builder for install operations across file ops (publishConfig, writeFile, mergeJson), source-injection ops (injectImport, injectBefore, injectAfter, injectProvider, injectConfigFactory, injectRoute), native ops (injectAndroidPermission, injectAndroidMetaData, injectAndroidGradlePlugin, injectAndroidGradleDependency, injectIosPlistEntry, injectIosPodEntry, injectMacosPlistEntry, injectMacosPodEntry, injectIntoWebHead, addWebMetaTag), and env ops (injectEnvVar). Operations enqueue against a sealed InstallOperation hierarchy with 26 final variants.
Idempotency, atomicity, reversibility #
ConflictDetectorflagsunmanaged-filewhen a target exists outside any recorded install (--forceoverride + scaffold-fingerprint heuristic auto-allows the default Flutter counter-app overwrite).InstallTransactionwrites via.tmp+ atomic rename; concurrent readers never observe partial state.ConfigEditor.insertCodeAfterPattern+insertCodeBeforePatternearly-return when the target already contains the code (idempotent re-install).PluginInstaller.injectProvider+injectConfigFactoryappend to the END of the list using lookahead-anchored regex(?=\s*\n\s*\])so new entries appear where readers expect them (6-space indent matches the scaffold style).InstallTransactionrecords every applied op to.artisan/installed/<plugin>.json(op type, target path, content hash).plugin:uninstallreversesWriteFile(delete + stub-hash tamper check);InjectImportandInjectAfterPatternlog[skipped](anchor-bracketed inject markers pending V1.1).
Signature DSL #
Command surface declared inline: String get signature => 'cmd:name {arg} {--flag=default}'. configure(ArgParser) remains available as an explicit fallback. The MCP server's per-command inputSchema is verified against the underlying command's argument declarations so the wire contract cannot drift from the CLI surface.
Codegen barrels #
lib/app/commands/_index.g.dart(consumer commands), regenerated bymake:commandandcommands:refresh.lib/app/_plugins.g.dart(plugin providers), regenerated byplugin:install <name>andplugins:refreshfrom the.artisan/plugins.jsonregistry.
Both write through .tmp + atomic rename; never hand-edit.
VM Service hooks #
tinkerevaluates Dart expressions against the connected isolate via the VM Service evaluate RPC. Magic facade autocomplete + Eloquent model casting come from the optionalmagic_tinkerintegration when registered.reload/hot-restartwriter/Rto theflutter runprocess's stdin via a POSIX FIFO bridge so detached processes still accept interactive commands.
Testable primitives #
VirtualFsinterface +InMemoryFsimplementation. Every installer pathway is unit-testable without touching the host filesystem.InstallContext.test(fs, prompt, stubs, clock, projectRoot)fixture builder.ArtisanContext.bare(MapInput, BufferedOutput)for command-level tests.BufferedOutputcapturesinfo/success/warning/errorlines for assertion.
Programmatic API #
runArtisan(args, baseProviders:, delegateToConsumer:, collectMcpTools:): universal entry point.- Single barrel:
package:fluttersdk_artisan/artisan.dartexposes the full public surface (Application,Command,Input/Output,ServiceProvider,Context,VmServiceClient,StateFile, stub system, helpers, installer, registry).
CI + automated publishing #
.github/workflows/ci.yml: format + analyze + tests + 80 % line-coverage floor (viacoverage:format_coverage+ awk gate) + dry-run archive on every push to master and every pull request..github/workflows/publish.yml: SemVer tag push triggers validate -> pub.dev publish via the officialdart-lang/setup-dart/.github/workflows/publish.yml@v1reusable workflow with OIDC authentication (no long-lived secret stored). Requires "Automated publishing from GitHub Actions" enabled on the pub.dev package admin page with the repository pinned tofluttersdk/artisan..github/dependabot.yml: weekly pub bumps (root +example/) plus weekly GitHub Actions version bumps..github/ISSUE_TEMPLATE/: structuredbug_report.yml,feature_request.yml,documentation.yml, plus aconfig.ymlthat disables blank issues. Bug + feature templates use a 14-option Subsystem dropdown matching thelib/src/layout.
Documentation #
README.mdtwo-path Quick Start (plain Flutter viaconsumer:scaffold; Magic-managed viamagic:install).- 17-file
doc/tree underhttps://fluttersdk.com/artisan/X/Y:getting-started/,commands/,mcp/,plugins/,reference/. skills/fluttersdk-artisan/: LLM-agent skill (SKILL.md+ 5 references:commands.md,install-yaml-schema.md,installer-dsl.md,mcp-server.md,plugin-authoring.md).llms.txtat repo root per llmstxt.org spec.
Compatibility #
- Dart SDK
>=3.4.0 <4.0.0. Pure Dart core; Flutter optional (only required by plugins that consume Flutter SDK APIs). - Platforms: Android, iOS, macOS, Linux, Windows. Web unsupported (relies on
dart:io). - V1 lifecycle commands (
start,stop,reload,hot-restart) use POSIX FIFO stdin pipes viamkfifo. macOS and Linux only; Windows unsupported for the lifecycle quartet (other commands work).