AutoLISP → AutoCAD .NET Migration Skill (Design Automation)
Converts AutoLISP routines into a production-ready AutoCAD .NET C# plugin for Design Automation. This is the sole target — there is no desktop/interactive output. Every migrated command is a parameterized, non-interactive entry point, never a dialog or a command-line prompt.
Core motivation: Visual LISP (the COM/ActiveX API — vlax-*, vla-*, vlax-invoke-method) does not work in Design Automation. The COM runtime is absent in headless accoreconsole. Basic AutoLISP (entget, ssget, entmod) does run in DA, but most real-world LISP plugins use Visual LISP COM calls and are therefore blocked. Migration to .NET typed API is the only path to cloud automation for those plugins. This skill removes the C# syntax barrier for LISP developers making that jump — and removes the ambiguity of trying to support both desktop and DA from one project: DA-only keeps every generated command to a single, consistent shape.
Environment Setup
Before generating any code, ask for the three paths below if they have not been provided. All generated files (csproj, RunIntegrationTests.ps1, PackageContents.xml, bundle scripts) must use the actual paths — never hardcode defaults into output files.
Ask once, remember for the whole session:
| # | Prompt | Used for |
|---|---|---|
| 1 | AutoCAD install folder? | Derives accoreconsole.exe path; SDK NuGet version to target |
| 2 | LISP project folder? (folder containing the .lsp files to convert) |
Source to read; determines output folder name |
| 3 | ARX SDK folder? (ObjectARX / AutoCAD .NET SDK root) | Reference samples in <sdk>\samples\dotNet\; confirms correct API surface |
Example prompt to user:
Before I start the migration, I need three paths:
- AutoCAD install folder (e.g.
D:\ACAD\AutoCAD 2027)- The folder containing the LISP files to convert
- ARX SDK root (e.g.
D:\Sdks\Arx2027)
Derived values (compute automatically once paths are known — do not re-ask):
accoreconsole = <autocad_folder>\accoreconsole.exe
sdk_dotnet = <sdk_folder>\samples\dotNet\
Infer serial, nuget_version, da_engine, and tfm from the AutoCAD year:
| AutoCAD year | serial (R-prefix) | nuget_version | da_engine | tfm |
|---|---|---|---|---|
| 2027 | R26.0 | 26.0.0 | Autodesk.AutoCAD+26_0 |
net10.0-windows |
| 2026 | R25.1 | 25.1.0 | Autodesk.AutoCAD+25_1 |
net8.0-windows |
| 2025 | R25.0 | 25.0.0 | Autodesk.AutoCAD+25_0 |
net8.0-windows |
- serial →
SeriesMin/SeriesMaxinPackageContents.xml - da_engine →
enginefield in APS Design Automation activity JSON - nuget_version →
AutoCAD.NET.Coreversion in.csproj(the only package this skill ever references — see Design Automation Guardrail) - tfm →
TargetFrameworkin.csproj
If the user has already provided any path in the argument, accept it and only ask for the missing ones.
Conversion Target
There is exactly one target: Design Automation. No --target flag, no per-migration choice — every migration produces one DA-ready plugin project and one DA bundle. See the Design Automation Guardrail below: no interactive input surface (dialogs or command-line prompts) belongs anywhere in the generated code, since there's no desktop counterpart to defer them to.
Execution Mode — Ask Once, Never Re-Ask
Side-effecting steps in this procedure (dotnet new scaffold, dotnet build, dotnet test, RunIntegrationTests.ps1, New-Bundle.ps1) can be run two ways. Ask which mode the user wants during Step 0, alongside the environment paths — then follow it silently for the rest of the migration. Do not ask again per-step; that produces exactly the ambiguous mid-flow interruptions this section exists to prevent.
| Mode | Behavior |
|---|---|
| Auto | Claude invokes dotnet new, dotnet build, dotnet test, and the PS1 scripts directly via its own tool calls. The end user's Claude Code permission settings still govern whether each tool call needs approval — this skill has no ability to change that. Choosing Auto only means Claude attempts the call instead of handing over text. |
| Pair-programming (default, matches this project's own CLAUDE.md convention) | Claude generates the exact command and asks the user to run it in their own shell. No tool call is attempted, so no permission prompt occurs — but the user must copy/paste and report results back. |
Example prompt to user (fold into the Step 0 paths question):
One more thing: should I run the scaffold/build/test/bundle commands myself (auto), or generate them for you to run in your own shell (pair-programming, default)?
If the user wants fewer permission prompts under Auto mode, that is a change to their own Claude Code settings (e.g. allowlisting dotnet build/dotnet test/dotnet new in .claude/settings.json) — point them to the update-config or fewer-permission-prompts skill for that. Do not attempt to suppress or bypass permission prompts from within this skill's own instructions.
DCL Guardrail — Evaluate Before Starting
Scan the .lsp file(s) for DCL usage before doing anything else. If DCL is detected, stop immediately and respond with a refusal message. Do not generate any code.
DCL detection patterns:
(load_dialog ...)
(new_dialog ...)
(done_dialog ...)
(action_tile ...)
(start_dialog ...)
(set_tile ...)
(mode_tile ...)
(term_dialog)
(unload_dialog ...)
Also check for a .dcl file with the same base name in the same folder.
Refusal response when DCL detected:
This LISP project uses DCL dialog boxes (
load_dialog/new_dialogdetected). DCL migration is out of scope for v1 — replacing DCL with WPF/Palette requires a design session to decide layout, data binding, and user flow.What you can do now:
- Extract the non-dialog logic (commands, entity ops, file I/O) and I'll migrate those to .NET, with every value the dialog gathered becoming a typed
params.jsonfield.- Flag the DCL mechanics themselves (
load_dialog/new_dialog/action_tile/etc.) as// TODO v2: replace with WPF dialogstubs — the values those tiles held are not stubbed, only the dialog-display mechanics.Reply "migrate non-dialog code only" to proceed with partial migration, or wait for v2.
If the user explicitly says "migrate non-dialog code only", proceed like this:
- The DCL tile values are interactive input, same as
getpoint/getkword/getstring— not dead code. Read the.dclfile alongside the.lspfile. For every tile that feeds a value the underlying command logic actually uses (edit_box,radio_button,popup_list,toggle), add a field to the<CommandName>Inputrecord, one field per tile, doc-commented/// Mirrors the "<tile-key>" dialog tile.— same convention as any other interactive input, and the same convention used forgetpoint-derived fields in the same record. Confirmed real pattern:FlangeDA'sHolePatternInput.cs(fieldsOffset,FlangeTangentAngleDegrees,PatternDiameter,NumberOfHoles,HoleDiametermap directly to theoffset/ftang/ddpcd/holediaDCL tile keys). - Only the DCL mechanics themselves get stubbed —
load_dialog/new_dialog/start_dialog/action_tile/set_tile/mode_tile/term_dialog/unload_dialogcalls become// TODO v2: replace with WPF dialog(or are simply omitted, since there's no dialog to show). The values those calls gathered are never stubbed — they're real, typed, tested fields, exactly like any other Design Automation Guardrail input. - The dialog's actual replacement is a separate Web UI layer, not this skill's output. A subsequent step converts the DCL tiles into a static HTML form plus a Node/Python server, using this migration's own
da/params.schema.jsonas the contract — the form's submission becomes theparams.jsonthis plugin already expects. That's a distinct build phase using the same LSP-to-.NET skill's output as its foundation, not a disconnected handoff to someone else's tool. Demonstrated end-to-end withFlangeDA.
Design Automation Guardrail — Applies to Every Command, Always
Since DA is the only target, this checklist applies to every [CommandMethod] in every migration — there is no separate desktop path to defer an interactive command to. A DA Activity has no display, no message pump, and no live console — a command that silently relies on any interactive input will not error cleanly at build time; it will hang or fail inside an Autodesk-hosted cloud worker, which is far harder to debug than a desktop crash. Treat this with the same seriousness as the DCL Guardrail, not as a footnote.
For every [CommandMethod], check for and eliminate:
getfiled→OpenFileDialog/SaveFileDialog— any GUI dialog. Zero tolerance.getstring/getkword/getpoint/getdist/getreal/getint/getangle→ed.GetString/ed.GetKeywords/ed.GetPoint/etc. Even though these can be driven via a.scrwith pre-supplied answers (used foraccoreconsoletesting elsewhere in this skill), a real DA Activity does not feed a script of typed answers — its inputs are pre-mapped parameter files. Any reliance on these is a defect, not a testing inconvenience.- Anything requiring on-screen entity selection (
ssgetwith no filter, "select objects:" prompts) with no non-interactive fallback (e.g. "select all of type X" is fine — typed/filteredssgetpatterns translate directly toOfType<T>(); open-ended interactive pick prompts do not).
Remediation — every one of these becomes a parameter, never gets dropped. Whatever the original LISP asked the user for interactively (a point, a distance, a filename, a keyword choice), the migrated command reads the same value as a method argument or from a fixed, DA-parameter-mapped JSON file (see GardenPath's GPathDaInput/params.json pattern for the template). Nothing about the original command's capability is lost — only the mechanism for supplying its inputs changes, from "ask interactively" to "read from parameters." Reuse the same Helpers/service logic split out from the parameter-reading Commands.cs methods (Step 3's Helpers/ derivation rule exists partly for this reason).
Package rule — always AutoCAD.NET.Core, one project, no exceptions. The DA execution engine is architecturally the same headless core as accoreconsole — AutoCAD.NET (full package, includes AcMgd.dll, the desktop UI layer) crashes it the same way it crashes accoreconsole with 0xC0000005. Since there is no desktop target to justify referencing AutoCAD.NET, this skill never generates a project that does — AutoCAD.NET.Core is the only package reference for the main plugin project, matching the test projects (Step 4). One project, one package, no split.
Deploying and testing against real APS Design Automation: the dotnet new acad-lisp scaffold includes da/APS-Common.ps1 (generic REST helpers — auth, AppBundle/Activity/WorkItem lifecycle, OSS upload/download — no per-migration edits needed), da/Deploy-And-Test-DA.ps1 (deploys the bundle + activity, submits a test WorkItem against the DA entry point, downloads the result; reads its shape from da/activity.json/da/params.example.json), and da/Reset-APSApp.ps1 (deliberate escape hatch — see below). Claude still authors da/activity.json and da/params.example.json per migration in Step 5 — they contain the actual DA command name and parameter fields, which are migration-specific and can't be templated generically. Requires $env:APS_CLIENT_ID/$env:APS_CLIENT_SECRET (an APS app with Design Automation + Data Management scopes) and a real seed .dwg to submit.
Fully-qualify every DA reference — bare IDs only work for resources you own directly. A real, confirmed bug from the first live WorkItem test: POST /workitems needs activityId as <nickname>.<activityId>+<alias> (e.g. madcad.HatchBDaActivity+dev), not the bare activity id — DA returned BadRequest: "Cannot parse id." / "could not be found" until this was fixed. The appbundles array inside an Activity body needs the same full qualification (<nickname>.<bundleName>+<alias>). APS-Common.ps1's Submit-WorkItem and Deploy-And-Test-DA.ps1 already build these correctly — if hand-editing DA REST calls, always qualify.
APS forgeapps nicknames are set-once, and the "unset" default is not blank. GET /forgeapps/me returns { "id": "<value>" } — but when no custom nickname has ever been registered, <value> defaults to the raw APS_CLIENT_ID itself, not null/empty. Checking truthiness alone (if ($current.id)) misreads the default identity as "already has a real nickname." The correct check compares against $env:APS_CLIENT_ID (APS-Common.ps1's Resolve-DANickname does this) — if they're equal, no nickname exists yet and it's safe to register one from user input; if they differ, a real nickname is already registered and must be used as-is (PATCHing again once the app owns any bundle/activity fails with "already has resources", whether or not the new value matches the old one). Never trust a -Owner/typed nickname over what Resolve-DANickname actually resolves.
Root-caused and fixed — two layered bugs, not one. Nickname resolution is the first call in the whole DA workflow, so get it right before anything downstream is trusted.
GET /forgeapps/mewas assumed to return{ "id": "<value>" }, but the observed response is a bare JSON string ("<value>") —Invoke-RestMethoddeserializes that to a plain .NET string with no.idproperty. Every caller reading$result.idon a plain string gets$nullsilently, with no error. Confirmed by pipingGet-DANickname $token | ConvertTo-Jsonand getting a bare quoted string back, not a JSON object. Fixed:Get-DANicknamenormalizes — returns the bare string directly if that's what it got, or falls back to.idif the response ever does come back as an object. This alone likely explains the original "misfire" mystery from earlier in this project's history.- Even after fixing (1), a second
Get-DANicknamecall made immediately after a failedSet-DANicknamePATCH attempt was still resolving empty — a second, separate flakiness, not fully root-caused. The actual fix needed no second API call at all:$current(fetched before the PATCH attempt) is still accurate whenever the PATCH fails, since nothing changed — there's nothing to re-fetch.Set-DANicknamenow returns$true/$falseinstead of swallowing the result, andResolve-DANicknamebranches on that directly: PATCH succeeded → return the requested nickname; PATCH failed → return the already-known$current. Simpler, and avoids whatever was wrong with the redundant second GET.
Algorithm now: get the current identity once; if it's already a real nickname (≠ $env:APS_CLIENT_ID), use it, ignore any requested value; otherwise attempt to PATCH the requested nickname, and use it only if the PATCH actually reported success — otherwise fall back to the already-known current identity. -Owner <known real nickname> still works as a fast manual override, but shouldn't be routinely necessary anymore.
GET /appbundles/GET /activities return the public catalog, not "mine," and only one page at a time. They list every publicly-published activity across all of APS — including other teams'/products' official entries (AutoCAD.PlotSheetsetToPDF, Fusion.helloFusion, etc., observed on a shared internal test APS_CLIENT_ID) — not resources scoped to the caller. A single call also only returns one page ({ data: [...], paginationToken: "..." }, ~20 items observed); use Get-DAAllPages (loops on paginationToken until absent) rather than a raw Invoke-DA call whenever completeness matters. To find what's actually yours, filter the full list client-side for IDs prefixed with your resolved identity ("$Owner.") — Reset-APSApp.ps1's pre-delete banner does this so its counts reflect what DELETE /forgeapps/me will actually remove, not an unrelated public-catalog snapshot. DELETE /forgeapps/me itself is correctly scoped to "me" regardless of what the list endpoints show — the list endpoints being public doesn't make the delete unsafe, it just made the banner's counts misleading before this fix.
Resolve every user-supplied file path parameter (e.g. -InputDwg) to absolute before it reaches raw .NET file I/O. A real bug: Upload-ToOSS uses [System.IO.FileStream], which resolves relative paths against .NET's Environment.CurrentDirectory — not PowerShell's $PWD. The two silently diverge (cd in PowerShell doesn't move .NET's own CWD), so a bare relative -InputDwg seed.dwg can resolve against a totally different directory (observed: it resolved to the shell's original startup folder, not the da\ folder the user had cd'd into) and fail with a confusing "Could not find file" pointing at the wrong path. Fix: Test-Path the param (this uses PowerShell's own path resolution correctly), then $InputDwg = (Resolve-Path $InputDwg).Path before it's used anywhere downstream. Deploy-And-Test-DA.ps1 does this for both -InputDwg and -ParamsJson, the two user-overridable path params that flow into Upload-ToOSS.
If DA resource state gets confused across runs (mismatched owner prefixes, a corrupted nickname), da/Reset-APSApp.ps1 -Confirm is the clean-slate escape hatch — DELETE /forgeapps/me, wiping every AppBundle/Activity/nickname for that APS_CLIENT_ID. It prints a warning banner with exact counts and requires typing DELETE before proceeding, and enforces a ~100s wait afterward (server-side deletion is not instant — racing it with an immediate Deploy-And-Test-DA.ps1 run risks stale-state errors). This is a deliberate, rarely-needed reset, never part of the normal deploy/test flow.
Generating a seed .dwg when the developer doesn't have one (no AutoCAD desktop needed): add a small dev-only [CommandMethod] (e.g. HBSEED) to the main plugin that builds whatever minimal entity the migrated command needs directly via the Database API (see HatchBDA's HbSeed() — a bare 10×5 Hatch via AppendLoop/EvaluateHatch, no dependency on the command under test). Then drive accoreconsole with no /i at all (it opens its own default blank drawing) and a script that sets FILEDIA 0 before QSAVE so the "Save Drawing As" prompt is answered on the command line instead of popping a (nonexistent, headless) dialog:
FILEDIA
0
SECURELOAD
0
NETLOAD
<absolute path>\<ProjectName>.dll
HBSEED
QSAVE
<absolute path>\seed.dwg
QUIT
Y
Every path handed to accoreconsole.exe must be absolute — the NETLOAD target inside the script, and the /s <script>//i <dwg> command-line arguments themselves. accoreconsole.exe is a separate process from the shell that invoked it — same class of CWD-divergence bug as the Upload-ToOSS/-InputDwg one above, just hitting the console executable's own argument/script resolution instead of a PowerShell param or a raw .NET FileStream. Resolve every one of these with (Resolve-Path ...).Path before use: & accoreconsole.exe /s (Resolve-Path seed.scr).Path, and the .dll path written into the script's NETLOAD line. Label the seed command clearly as dev/test-only, not part of the migrated LISP surface, and never wire it into PackageContents.xml's <Commands> list.
Skip this entirely if the migrated command builds geometry from scratch and never reads pre-existing entities (e.g. a form-driven generator like a parametric part-drawing command — inputs are all in params.json, nothing depends on what's already in the drawing). In that case any blank drawing works as input; just run accoreconsole with no /i directly against the real command, no dev-only seed command needed at all. Only build a seed command when the migrated command's Discovery Table shows it reads/selects/computes against entities that must already exist (AcresDA, HatchBDA) — not when it only writes new ones (Flange).
Author outputFile to reuse the input's localName, not a new SAVEAS name. activity.json's outputFile parameter can deliberately share localName with inputFile (e.g. both "input.dwg") — the command line's final QSAVE (no filename) then writes back to the file accoreconsole already has open, which DA uploads as the result. This avoids an untested SAVEAS <newname> scripting step and is the safer default unless the migration genuinely needs a differently-named output.
Scope
In Scope (v1 — AU 2026)
- AutoLISP command functions (
defun C:CMD) - Selection sets (
ssget,ssname,sslength) - Entity operations (
entget,entmod,entdel,entmake) - Property access via DXF group codes (
assoc) - VLA-Object / ActiveX (
vlax-ename->vla-object,vla-get-*,vla-put-*) - File I/O (
open,write-line,close) - String / math utilities (
strcat,itoa,rtos,atof) - Unit tests for all commands
- Design Automation bundle + activity deployment
Out of Scope (v2)
- DCL dialog box UI migration (CUIX/WPF replacement — needs design session)
(command ...)sequences that depend on live, mid-command user interaction (e.g.pauseinside acommandcall waiting on cursor drag, or a genuine multi-step wizard that can't be pre-supplied) — these have no DA equivalent at all, not even a parameter, since the "input" is continuous interaction, not a discrete value- Complex reactor chains
- AutoLISP reactor / event-driven code
Not out of scope, despite first appearances: (command ...) sequences that just construct or edit entities from already-known values (circle, line, ellipse, array, layer, change, mtext, rotate with a fixed angle) almost always have a direct typed API equivalent — build the entities directly via AppendEntity/Transaction, compute array/polar points yourself instead of calling the ARRAY command macro, use Entity.TransformBy(Matrix3d.Rotation(...)) instead of an interactive ROTATE ... pause. Confirmed across two real migrations (AcresDA, Flange) where zero (command ...) calls survived into the final C# — every one had a typed replacement. Only the genuinely-interactive subset above stays out of scope.
Pattern Mapping Reference
Detailed AutoLISP → C# mapping tables (Selection Sets, DXF Group Codes, VLA-Object/COM, File I/O, String/Math, Command Registration) live in references/patterns.md — load it when Step 1's Discovery Table needs a specific mapping not already obvious from context.
Migration Procedure
Step 0 — Confirm environment paths
If not already provided, ask for the three paths (see Environment Setup above). Resolve derived values before proceeding:
autocad_folder = <user answer 1> e.g. D:\ACAD\AutoCAD 2027
accoreconsole = <autocad_folder>\accoreconsole.exe
lisp_folder = <user answer 2> e.g. D:\MyProject\lisp
sdk_folder = <user answer 3> e.g. D:\Sdks\Arx2027
sdk_dotnet = <sdk_folder>\samples\dotNet
nuget_version = derived from AutoCAD year
tfm = derived from AutoCAD year
Use these variables in every generated file — do not hardcode paths.
Step 1 — Analyze the .lsp file
Read every line. Build a Discovery Table — this drives every decision in Steps 3 and 4.
| Category | What to find | .NET mapping |
|---|---|---|
| Commands | Every (defun C:NAME ...) |
[CommandMethod("NAME")] public void Name() |
| Helpers | Every (defun name ...) without C: prefix |
internal static ReturnType Name(params) |
| Globals | Top-level (setq *VAR* ...) |
Class-level field private T _var |
| Entity types | ssget filter lists, (cdr (assoc 0 ...)) checks |
OfType<Line>(), OfType<Hatch>(), etc. |
| DXF reads | (assoc N entdata) for any group code N |
Look up typed property from the pattern table |
| DXF writes | (entmod ...) with (cons N val) |
entity.Property = value after UpgradeOpen() |
| COM calls | vlax-invoke-method, vla-get-*, vla-put-* |
Typed .NET equivalent from the pattern table |
| User input | (getpoint), (getreal), (getstring), (getdist), (getfiled) |
Never ed.GetPoint()/ed.GetString()/dialogs — becomes a field on the DA parameter record (e.g. GPathDaInput), read from params.json. See Design Automation Guardrail. |
| File I/O | (open ...), (write-line ...), (close ...) |
StreamWriter / JsonSerializer |
| Math/String | (sin), (cos), (strcat), (itoa), (atof) etc. |
Direct C# / Math.* / string interpolation |
| DCL | load_dialog, new_dialog, action_tile |
OUT OF SCOPE — v2 TODO stub |
Unknown patterns: If a LISP function or group code is not in the pattern table, reason from context:
- Unknown DXF group codes → look up in the AutoCAD DXF Reference (entity type → code → typed property)
- Unknown
vlax-invoke-method→ find the equivalent method on the managed wrapper class - Unknown
getenv/setenv→ useEnvironment.GetEnvironmentVariable/SetEnvironmentVariable - When genuinely ambiguous, emit a
// TODO: verify — original LISP: (form)comment and continue
The Discovery Table is the contract between Step 1 and Steps 3–4. Every row in it becomes a specific piece of generated code.
Step 2 — Scaffold .NET project
The acad-lisp-migration dotnet new template generates the full project structure. Claude fills the migration-specific code into the generated stubs in Steps 3–4.
One-time install (not per-migration) — check first with dotnet new uninstall (list mode) whether acad-lisp-migration is already registered, then in the chosen Execution Mode:
dotnet new install <skill-folder>/templates/acad-lisp-migration
(<skill-folder> is the folder this SKILL.md lives in — e.g. ~/.claude/skills/lisp-to-dotnet once deployed. templates/ is a direct sibling of SKILL.md, not nested under another skill/.)
Per-migration scaffold:
dotnet new acad-lisp -n <ProjectName> --AutoCADVersion <year> -o <ProjectName>
(--AutoCADVersion is auto-derived from the template's symbol name by the dotnet new engine — case-sensitive, PascalCase, not kebab-case. Shorthand -A <year> also works.)
Where <year> is 2025, 2026, or 2027 resolved from Step 0.
Auto mode: run both commands directly. Pair-programming mode: print both commands and wait for the user to confirm they ran the scaffold command before reading generated files in the next step.
The template produces a complete scaffold with TFM, NuGet versions, and PackageContents.xml serial pre-resolved for the chosen AutoCAD version. This does not include the actual accoreconsole filesystem path — RunIntegrationTests.ps1's -Accore default is the standard Autodesk installer location (C:\Program Files\Autodesk\AutoCAD <year>\accoreconsole.exe), which is correct for most customers but is still a template placeholder, not this specific customer's confirmed path. Before handing the file to the user (or running it in Auto mode), replace the default with the actual accoreconsole path resolved from Step 0's answer if it differs — this skill must work in the customer's environment, not just the one it was authored on.
<ProjectName>/
<ProjectName>.csproj ← correct TFM + AutoCAD.NET.Core version (only package used)
App.cs ← IExtensionApplication stub
Commands.cs ← [CommandMethod] stub ← Claude fills (Step 3)
PackageContents.xml ← correct SeriesMin/Max
Tests/<ProjectName>.Tests/
<ProjectName>.Tests.csproj ← xUnit + AutoCAD.NET.Model
CommandTests.cs ← placeholder ← Claude fills (Step 4)
Tests/Integration/
<ProjectName>.IntegrationTests.csproj ← NUnit + NUnitLite + ExtentReports + AutoCAD.NET.Core
Infrastructure/
AppEntry.cs ← assembly-level attributes, ready to use
DrawingTestBase.cs ← transaction + ExtentReports base
RunTestsCommand.cs ← RunCADtests command, ready to use
TestReport.cs ← ExtentReports wiring, ready to use
TestData.cs ← statics stub ← Claude fills (Step 4)
TestSetupCommands.cs ← [CommandMethod] stub ← Claude fills (Step 4)
IntegrationTests.cs ← placeholder test ← Claude fills (Step 4)
RunIntegrationTests.ps1 ← pre-wired for chosen AutoCAD version
da/
APS-Common.ps1 ← generic APS REST helpers, ready to use
Deploy-And-Test-DA.ps1 ← deploys bundle+activity, submits a test WorkItem
AGENTS.md ← AI coding context for the generated project
activity.json and params.example.json are authored by Claude in Step 5 (migration-specific content — the actual DA command name and parameter fields), while APS-Common.ps1/Deploy-And-Test-DA.ps1 are ready to use as scaffolded. See the Design Automation Guardrail for what belongs in every generated command.
After the user runs the scaffold command, read the generated stubs and proceed to Step 3.
Step 3 — Generate C# code
The file structure is derived from the Discovery Table, not fixed. Only create what the LISP actually contains.
<ProjectName>/
App.cs ← always: IExtensionApplication + assembly attributes
Commands.cs ← always: one [CommandMethod] per discovered C: command
Helpers/<GroupName>.cs ← only if: helper defuns exist; group by logical concern
Models/<EntityName>Data.cs ← only if: 3+ related fields are extracted from one entity type
Models/<CommandName>Input.cs ← if: the command took any getpoint/getstring/getfiled/etc. input — always parameterized, even for a single value
da/params.schema.json ← always if Models/<CommandName>Input.cs exists — machine-readable mirror of it
Derivation rules:
- Commands.cs — one
public void CmdName()per(defun C:NAME ...). Use the Discovery Table to fill the body: entity filters, DXF→property reads, writes, file output. - Helpers/ — create if there are non-trivial helper
defuns. Name the file after the logical group (e.g.,GeometryHelper.cs,StringHelper.cs,LayerHelper.cs). Skip if helpers are simple one-liners inlined in the command. - Models/
<EntityName>Data.cs— create arecordonly when a command builds a structured data object from 3+ related fields. Name it after the entity concept (e.g.,HatchBoundary,TextItem,BlockRef). Skip if data is just passed through inline. - Models/
<CommandName>Input.cs— create whenever the original LISP command took any interactive input (getpoint,getstring,getfiled,getkword, etc.), regardless of how many values. One field per input, in the order the original prompted for them. This is not optional and not conditioned on field count — see Design Automation Guardrail. - da/
params.schema.json— whenever a<CommandName>Input.csrecord exists, also emit a plain JSON Schema mirror of it: one property per field, withtype(boolean/string/number/array/etc. mapped from the C# type),default(from the record's= ...initializer, if any), anddescription(copied from the field's XML doc comment). This is the machine-readable contract for anything that needs to produce a validparams.jsonwithout reading C# — a hand-built form, a different skill generating a UI, a validation step. It is not DA-specific config and not a DCL/HTML feature; it's the same Input record, just in a format non-.NET tooling can consume. Keep it in sync with the record — regenerate whenever a field is added/renamed/retyped. - App.cs — always include both
InternalsVisibleTo("<ProjectName>.IntegrationTests")andInternalsVisibleTo("<ProjectName>.Tests")—internalhelpers need visibility from the unit test project too, not just integration tests, ordotnet testfails withCS0122.
Per-command body pattern — reads parameters, never prompts:
[CommandMethod("CMDNAME")]
public void CmdName()
{
var db = HostApplicationServices.WorkingDatabase;
string paramsPath = Path.Combine(Environment.CurrentDirectory, "params.json");
var input = JsonSerializer.Deserialize<CmdNameInput>(File.ReadAllText(paramsPath))
?? throw new InvalidOperationException($"Failed to parse {paramsPath}");
using var tr = db.TransactionManager.StartTransaction();
try
{
// body derived from Discovery Table rows for this command, reading from `input`
// instead of interactive getpoint/getstring/getfiled calls
tr.Commit();
}
catch (System.Exception ex)
{
tr.Abort();
ed.WriteMessage($"\nERROR: {ex.Message}\n");
// do NOT rethrow — see "Never let an exception escape a [CommandMethod]" below
}
}
Never let an exception escape a [CommandMethod] uncaught — always catch, log via Editor.WriteMessage, and return. A raw .NET exception crossing out of a command isn't a clean managed unwind inside AutoCAD's process — it's treated as a native-level fault and triggers AutoCAD's own crash/error-report flow (the same "Send error report" dialog a desktop crash shows). Headless accoreconsole/DA has no one to dismiss that dialog, so instead of a clean failure with a diagnostic message, the WorkItem hangs or times out opaquely. Confirmed empirically: a deliberate throw new NotSupportedException(...) for a genuine capability gap (see the SupportPath row below) triggered exactly this. Signal failure to DA via the Editor.WriteMessage log (visible in the WorkItem report) and/or a missing expected output file — never via an uncaught exception.
WriteMessage-style logging is still available (AcCoreMgd.dll includes the Editor) — but Autodesk.AutoCAD.ApplicationServices.Application (the bare desktop type) does not exist in a Core-only project and fails with CS0234 (AcMgd.dll-only type, never referenced here — see the Design Automation Guardrail). Use Autodesk.AutoCAD.ApplicationServices.Core.Application instead — same DocumentManager.MdiActiveDocument.Editor.WriteMessage(...) surface, just the .Core namespace segment. Confirmed by an actual dotnet build failure/fix during the gpmain.lsp GardenPathDA migration; the scaffold's own App.cs template had this bug and has since been corrected.
Use Editor.WriteMessage, not Console.WriteLine, for all command output/logging — never treat Console.WriteLine as an equivalent fallback. accoreconsole happens to surface stdout locally, which makes Console.WriteLine look like it works during integration testing — but the real cloud Design Automation engine is not guaranteed to capture bare stdout the same way. Editor.WriteMessage writes through AutoCAD's own command-line/report pipeline, which is what actually ends up in the WorkItem's downloadable report log. Every (princ ...)/(prompt ...) call in the original LISP should map to Editor.WriteMessage, full stop — this replaces the earlier (incorrect) guidance that Console.WriteLine was a safe fallback needing no verification.
Watch for CS0104 ambiguous references — a whole category, not a one-off. Several AutoCAD.NET namespaces shadow common BCL/WinForms type names. When both are using'd, the bare name is ambiguous and fails to compile. Always fully qualify the AutoCAD one, or alias it. Known collisions found in real migrations so far:
| Bare name | AutoCAD type | Collides with | Fix |
|---|---|---|---|
Exception |
Autodesk.AutoCAD.Runtime.Exception |
System.Exception |
catch (System.Exception ex) |
Application |
Autodesk.AutoCAD.ApplicationServices.Application |
System.Windows.Forms.Application |
using AcApp = Autodesk.AutoCAD.ApplicationServices.Application; |
Color |
Autodesk.AutoCAD.Colors.Color |
System.Drawing.Color |
Fully qualify or alias AcColor |
General rule: any time a command uses System.Windows.Forms (file dialogs, message boxes) alongside AutoCAD namespaces, expect Application and possibly Color/Point/Rectangle collisions too. Check for ambiguity proactively when both namespace families are in the same file — don't wait for the compiler to find it one type at a time. Add newly discovered collisions to this table.
A different, missing-using class of error — not ambiguity, absence. var ed = doc.Editor; resolves member access without ever needing Autodesk.AutoCAD.EditorInput imported, since the compiler never needs the type name spelled out. The gap stays invisible until a helper method's signature explicitly types an Editor parameter (e.g. private static int Foo(Database db, Transaction tr, Editor ed, ...)) — that fails CS0246 unless using Autodesk.AutoCAD.EditorInput; is present. Whenever a helper method takes Editor (or any AutoCAD type) as an explicit parameter type rather than through var, double-check its containing file's using list — don't assume the main command body's imports are automatically sufficient. Found during BlockToXrefDA (Helpers-heavy migration with a private static helper taking Editor ed); earlier migrations missed this because none had passed Editor as an explicit helper parameter yet.
Step 4 — Tests
Tests are derived from the Discovery Table. Every command that touches the database or produces output gets tested.
Tier 1 — xUnit (dotnet test, no AutoCAD host)
For every helper or utility function whose logic is pure (math, string, boolean, parsing — no AutoCAD types):
[Theory]
[InlineData(...)]
public void HelperMethod_Scenario_ExpectedResult() => Assert.Equal(expected, Helper.Method(input));
Rule: if the code compiles and runs without accoreconsole, it belongs in Tier 1. Do not use Point3d, ObjectId, or any AutoCAD type in xUnit tests — they require the host.
Tier 2 — NUnit inside accoreconsole (RunIntegrationTests.ps1)
Use the coreconsolerunner pattern. Generated from the Discovery Table as follows:
TestData.cs — add one property per measurable output of each C: command. Choose value types only:
- Entity created: capture key geometry (counts, coordinates as
Point2d/double, flags asbool) - File output: capture path as
string, content summary asstringorintline count - Database modification: capture the modified property value (layer name, color index, etc.)
Never store ObjectId, DBObject, or any reference type that requires a live transaction.
TestSetupCommands.cs — one [CommandMethod] per command being tested. Each:
- Calls the migrated command method with test inputs (hardcoded or from a fixed drawing state)
- Captures results into
TestDatastatics - Commits the transaction
Name the command <ProjectName>SetupTest (template pre-fills this).
IntegrationTests.cs — one [Test] per property in TestData:
[OneTimeSetUp] public void CheckSetup() =>
Assert.That(TestData.Initialized, Is.True, "<ProjectName>SetupTest did not run.");
[Test] public void <Property>_<ExpectedCondition>() =>
Assert.That(TestData.<Property>, Is.<Constraint>);
**Package rule (test project, matching the main plugin pr
…(truncated)