Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
- Choose project identity
- Generate the project layout
- Keep tool files separated
- Build the generated project
- Boundary 1User-owned app
- Boundary 2Tool-owned workspace
- Boundary 3Generated project configuration
Connected responsibilities, not a required class hierarchy or an execution trace.
Load this when: designing what a tool writes into a user's project, deciding where caches and state belong, or reviewing a scaffold that puts more than a handful of entries at the project root.
Covers the layout ios-agent generates, the rule that decides where any new
file goes, why hiding internals is not cosmetic, and how the CLI and the MCP
server agree on a project root without configuring each other.
Implementation: cli/ (the ios-agent package). Every claim here is enforced
by a test in cli/test/.
1. The layout
MyApp/
├── App/ # the user's source — the only directory they edit
│ ├── MyApp/
│ └── MyAppTests/
├── README.md
├── LICENSE
├── .gitignore
└── .ios-agent/ # tool-owned; deleting it loses nothing
├── .gitignore # generated
├── config.json # tracked
├── state.json
├── metadata.json
├── cache/
├── logs/
├── build/
├── screenshots/
├── templates/ # tracked
├── plugins/ # tracked
└── tmp/
With --minimal, the whole project is MyApp/App/, and .ios-agent/
materialises the first time a command needs it.
Create from a description
ios-agent new TeaLog --brief 'An offline tea journal with tasting notes' --xcodegen
cd TeaLog/App
xcodegen generate --spec project.yml
open TeaLog.xcodeproj
--brief saves the description and an implementation checklist in
App/APP_BRIEF.md for your coding agent. It does not call an AI service or
implement the described features. Both flags are optional and work with
--minimal; the visible root remains App/, README.md, and LICENSE
(or only App/ in minimal mode).
--xcodegen writes an editable App/project.yml, App/BUILD.md, and separate
SVG starters under App/<Name>/IconLayers/. The specification includes an iOS
17+ SwiftUI app, a unit-test target, and a shared scheme. The included test is a
placeholder to replace before shipping. Requires macOS, Xcode 15+ with an iOS
simulator runtime, and XcodeGen installed separately. The CLI neither installs
nor invokes them. Use an XcodeGen version compatible with your Xcode.
The layer folder contains background, foreground, and accent SVGs plus a manifest
and import instructions. Import them into Icon Composer
using a compatible Xcode installation, customize the appearance, save a native
icon, configure the target, and validate it in Xcode. The manifest is our source
layer inventory, not Apple's format; no native .icon is generated or validated.
The starter layers are excluded from app resources.
--force permits a non-empty destination but refuses existing generated file
paths or symlink destinations before writing. It never overwrites your source,
brief, specification, README, or configuration. Choose a new app directory when
regenerating a starter.
Project configuration follows the XcodeGen specification.
The CLI emits no .xcodeproj; running XcodeGen creates the real project.
2. The rule that decides where anything goes
Split by authorship, not by importance.
If a human writes it, it is visible. If the tool writes it, it is hidden.
Importance is the tempting axis and it is the wrong one, because it has no edge. Everything feels important to whoever added it, so a layout sorted by importance grows a new root directory per release until the project root is a list of implementation details. Authorship has a sharp edge: either a person typed it or a program emitted it, and nobody argues about which.
Two consequences fall out immediately, and both are worth more than the tidiness:
clean needs no confirmation prompt. Nothing in .ios-agent/ was authored,
so there is nothing to lose. A prompt would be theatre.
Deleting .ios-agent/ is a supported recovery step. "Delete it and re-run"
is only safe advice if it is structurally true, and here it is — which turns the
most common support answer in this class of tool from a risk into a fix.
The one exception, and why it is not a violation
config.json is tracked, and a human may edit it. That is the same bargain
.git/config makes: tool-managed by default, legible and editable by anyone who
opens it. It stays inside .ios-agent/ because it is maintained by the tool —
ios-agent init writes it, and future commands will update it.
If it ever becomes a file people are expected to hand-edit as the primary
workflow, it should move to the root and become visible, the way pubspec.yaml,
app.json, firebase.json, and Cargo.toml all are. No professional tool
hides configuration its users are expected to author — a hidden file is one
they cannot discover, and one that reads as noise in a pull request.
3. Why hiding internals is not cosmetic
Four concrete costs, none of them aesthetic:
A root directory is an API. Anything visible gets referenced — in a script,
a CI job, a README someone wrote. cache/ at the root will be depended on
within a release, and then it cannot be renamed. Inside .ios-agent/, the whole
tree stays private and refactorable.
Every root entry is a question the user has to answer. Nine directories at
the root is nine decisions about whether to touch each one, made by someone who
wanted to write a view. One App/ is no decision at all.
Review noise trains people to skim. A generated metadata/ that changes on
every build turns pull requests into scroll-past exercises, and reviewers who
learn to skim generated files skim the real ones too.
.gitignore drift is a silent failure. With internals at the root, every new
tool directory needs a matching root .gitignore line that someone must
remember. They forget, a cache lands in the repository, and nobody notices until
a clone is slow. The generated .ios-agent/.gitignore uses ignore-everything
then unignore, so a directory added tomorrow is ignored the moment it is
declared.
What the reference tools actually do
| Tool | Visible, human-authored | Hidden, tool-authored |
|---|---|---|
| Flutter | pubspec.yaml, lib/ |
.dart_tool/ |
| Expo | app.json, app/ |
.expo/ |
| Firebase CLI | firebase.json |
.firebase/ |
| Cargo | Cargo.toml, src/ |
target/ |
| npm | package.json, src/ |
node_modules/ |
| Xcode | project.pbxproj |
xcuserdata/, DerivedData |
| git | — | .git/ |
The pattern is unanimous, and the split is authorship every time. Note also that
every one of them keeps its hand-edited config visible at the root — which
is the point in §2 about where config.json would have to move.
4. One declaration, four behaviours
The design's leverage is not the directory name; it is that everything derives
from a single table (cli/src/layout.ts):
export const INTERNAL_ENTRIES: readonly InternalEntry[] = [
{ name: "config.json", kind: "file", tracked: true, eager: true, purpose: "…" },
{ name: "cache", kind: "directory", tracked: false, eager: false, purpose: "…" },
// …
];
Adding a future feature — simulator state, plugin cache, build artifacts — is one row. That row then automatically produces:
- its entry in the generated
.ios-agent/.gitignore - inclusion in, or exclusion from,
ios-agent clean - a path in
ios-agent where --json - a
doctorcheck that it has not leaked to the project root
The alternative — a constant here, a gitignore line there, a clean list
somewhere else — is three places that must be edited together and eventually are
not. The failure mode is specific and quiet: a new cache directory that clean
skips and git happily commits.
This is also why the tests assert the relationship rather than the contents:
test("clean never targets a tracked entry", () => {
const disposable = new Set(disposableEntries().map((e) => e.name));
for (const name of INTERNAL_ENTRIES.filter((e) => e.tracked).map((e) => e.name)) {
assert.ok(!disposable.has(name), `${name} is both tracked and disposable`);
}
});
That test does not care what the entries are. It fails the day someone adds a row that is both tracked and deletable, which is the bug that would cost a user their template overrides.
The same move, applied to the command surface
COMMANDS in cli/src/commands.ts is the second instance of the pattern.
Dispatch, help, and the bash and zsh completion scripts all read one table, so
help text cannot describe a flag the parser rejects and completions cannot offer
a command that no longer exists. Hand-maintained help drifts, and the drift is
invisible until a user follows the documentation and gets an error.
The test is the same shape as the layout one — it asserts coverage rather than content:
test("completions cover every dispatchable command", () => {
for (const shell of ["bash", "zsh"]) {
const script = capture(run(["completions", shell]));
for (const name of commandNames()) assert.ok(script.includes(name));
}
});
Exit codes are part of the interface
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Usage error, or no project found |
| 2 | doctor found problems |
doctor returns 2 rather than 1 so a caller can distinguish "the project is
unhealthy" from "you invoked it wrong". Collapsing both into 1 forces scripts to
parse stderr, which is exactly what --json exists to prevent.
What a --fix flag may repair
Only defects with a derivable correct value: a stale generated gitignore, a
missing internal directory, a config behind the current layout version. A
missing App/ is reported and left alone — creating it would invent a project
structure the user never asked for.
The line is worth stating explicitly because it is easy to cross by accident: a
--fix that guesses turns a diagnostic into a source of surprise changes, and
users stop trusting the command that was supposed to be the safe one.
5. Root discovery — how two processes agree
.ios-agent/ doubles as a root marker, exactly as .git/ does. Both the
CLI and the MCP server walk up from the working directory until they find one:
1. an explicit --project / argument
2. IOS_AGENT_HOME (CLI) or IOS_AGENT_PROJECT (server)
3. the nearest ancestor containing .ios-agent/
4. cwd (server only — the CLI reports failure instead)
This is why ios-agent where works from App/MyApp/Views/, and it is why the
MCP server no longer analyses whatever subtree a client happened to spawn it in.
Both report how the root was resolved, not just what it is:
{ "project_root": "/Users/you/MyApp", "resolved_from": "marker" }
An implicit root is unfalsifiable. Without resolved_from, "0 Swift files" is
identical whether the project is empty or the tool is pointed at the wrong
directory — and that ambiguity is the most expensive minute in using a tool like
this.
The server reads the marker; it never creates it. That keeps
ios-agent-mcp's declared filesystem: read, network: none contract intact.
Scaffolding writes, so it lives in a separate package rather than quietly
turning the analyzer into something that mutates your project.
Interop
Other tools should ask rather than hardcode:
ios-agent where --json
One process owns the layout; everything else queries it. A rename then stays a change in one package instead of a coordinated release across several.
6. Cross-platform
Per-project vs. user-level caches are different things. Anything shared
across projects — downloaded templates, SDK metadata, plugin code — belongs in
the user-level cache, or every project duplicates it and every fresh clone
re-downloads it. .ios-agent/cache/ is only for data derived from this
project.
| Platform | User-level cache |
|---|---|
| macOS | ~/Library/Caches/ios-agent |
| Windows | %LOCALAPPDATA%\ios-agent\Cache |
| Linux/other | $XDG_CACHE_HOME/ios-agent, else ~/.cache/ios-agent |
~/.ios-agent is not on that list deliberately: a dotfile in $HOME is the
convention every platform has since moved away from, and on macOS and Windows it
is excluded from the OS's own cache-eviction handling.
Windows specifics:
- A leading dot does not hide a directory in Explorer.
doctorsays so and suggestsattrib +h, rather than pretending the name is enough. CON,PRN,AUX,NUL,COM1–COM9,LPT1–LPT9are reserved regardless of extension.validateProjectNamerejects them on every platform, so a project created on macOS still checks out on Windows.- Tracked paths in
config.jsonare stored POSIX-style. That file crosses machines by design, andApp\MyAppis unreadable on the Mac that builds it.
macOS specifics: the default filesystem is case-insensitive but
case-preserving, so App/ and app/ collide on a Mac and not on Linux CI.
Never generate two paths differing only in case.
Anti-Patterns
# WRONG — internals at the project root.
MyApp/
├── cache/ # depended on by a script within a release, now frozen
├── logs/ # in every pull request
├── metadata/ # regenerated on build, diffed by humans forever
├── config/
├── generated/
└── App/ # the one directory the user wanted, 1 of 6
# RIGHT — one hidden directory.
MyApp/
├── App/
└── .ios-agent/
// WRONG — the internal directory name written out at a call site.
const cache = path.join(root, ".ios-agent", "cache");
// A rename half-lands: the CLI writes to the new directory while the MCP
// server still reads the old one, and the symptom is an empty result with no
// error anywhere.
// RIGHT — derive from the layout.
const { cache } = layoutFor(root);
// WRONG — clean deletes a hand-maintained list.
const REMOVE = ["cache", "logs", "tmp"];
// It drifts from the gitignore the first time someone adds a directory to one
// and not the other. Then either a cache gets committed, or clean deletes a
// tracked template override.
// RIGHT — derive both from one declaration.
for (const entry of disposableEntries()) { … }
// WRONG — the tool falls back to cwd and says nothing.
const root = process.cwd();
// "0 Swift files" now means either an empty project or a wrong directory, and
// nothing in the output distinguishes them.
// RIGHT — resolve, and report how.
const { root, source } = resolveRootFrom(argv, env); // "flag" | "marker" | "cwd"
// WRONG — a per-project cache for data that is not project-specific.
const templates = path.join(root, ".ios-agent", "cache", "templates");
// Ten projects, ten copies, ten downloads.
// RIGHT — user-level, per platform.
const templates = path.join(globalCacheDir(), "templates");
// WRONG — ~/.ios-agent as the user-level location.
const home = path.join(os.homedir(), ".ios-agent");
// Ignored by macOS cache eviction and by Windows roaming rules, and wrong on
// every platform's own convention.
// RIGHT — the documented per-platform directory, overridable.
globalCacheDir(process.env, process.platform);
// WRONG — the project name accepted as given.
fs.mkdirSync(path.join(parent, name));
// "../evil" escapes the parent, "my-app" is not a Swift type name, and "CON"
// produces a directory Windows cannot open.
// RIGHT — validate before anything exists on disk.
validateProjectName(name);
// WRONG — scaffolding into a directory with contents already in it.
fs.mkdirSync(root, { recursive: true });
write(readme);
// Silently overwrites a README someone spent an afternoon on.
// RIGHT — refuse, and make --force explicit and non-destructive.
if (existing.length > 0 && !force) throw new ScaffoldError(…);
// WRONG — a single source directory, to be widened later.
{ "sourceDir": "App/MyApp" }
// Multiple apps were always coming, and the widening is a breaking change to
// every consumer of a tracked file.
// RIGHT — a list from day one, even with one entry.
{ "apps": [{ "name": "MyApp", "path": "App/MyApp", "platforms": ["iOS"] }] }
# WRONG — writing a fake .xcodeproj or claiming source files are a built app.
# RIGHT — emit Swift sources and an optional XcodeGen specification.
# Let Xcode or XcodeGen create the real project, then verify its build.
Checklist
- [ ] Exactly one tool-owned directory, and it is hidden
- [ ] Nothing the tool writes appears at the project root — asserted by a test
- [ ] Every internal path derives from one declaration, not a string literal
- [ ] The gitignore is generated from that declaration, not hand-maintained
- [ ] What
cleandeletes is the complement of what is tracked, provably - [ ] Deleting the internal directory loses nothing a human authored
- [ ] The internal directory is created lazily, not as an empty promise
- [ ] Root discovery walks up from cwd, like git
- [ ] Every path-reporting output states how the root was resolved
- [ ] A machine-readable
where --jsonexists, so nothing else hardcodes the name - [ ] Read-only consumers stay read-only — scaffolding lives in its own package
- [ ] Cross-project caches are user-level, per each platform's own convention
- [ ] Windows reserved names rejected on every platform
- [ ] Tracked config stores POSIX-style paths
- [ ] Project names validated before anything is written
- [ ] A non-empty target directory is refused unless explicitly forced
- [ ] Config carries a layout version, and a newer one is refused rather than rewritten
- [ ] Lists are lists from day one where more than one is coming
- [ ]
doctorfails when the layout is broken — proven by mutation tests - [ ]
--fixrepairs only what has a derivable correct value, and never invents structure - [ ] Help text and shell completions are generated from the command table, not maintained
- [ ] Exit codes distinguish usage errors from an unhealthy project
- [ ]
--jsonis available on every command a script would call