Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.4.1 — 2026-10-11

### Fixed

- `typecheck` on existing web projects uses the framework's checker instead of `tsc --noEmit`, which can't resolve Astro virtual modules or single-file components: `astro check` (adds `@astrojs/check`), `nuxi typecheck`, `svelte-kit sync && svelte-check` or `svelte-check`, and `vue-tsc` (each adds its checker package when missing). An existing `typecheck` script is left alone.
- Projects with a solution-style `tsconfig.json` (`"files": []` plus `references`, as in the Vite templates) get `tsc -b --noEmit`. Plain `tsc --noEmit` checked nothing there, so a fresh `--framework vite` project passed typecheck with type errors.
- Vendored skills no longer break the project's linters and formatters. `aginit init` and `aginit update` add `.agents/` and `.claude/` to `.prettierignore`, to `.eslintignore` (legacy ESLint, oxlint), to the global ignores of a flat ESLint config (`globalIgnores([...])` or an ignores-only object), and to `files.includes` in `biome.json` (Biome 2.2+). Configs that can't be edited safely get a warning with the exact line to add.
- `aginit doctor` reports linters and formatters that still check vendored skills.

## 0.4.0 — 2026-10-11

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@antomanc/aginit",
"version": "0.4.0",
"version": "0.4.1",
"description": "Set up a new or existing project for AI coding agents in one command: AGENTS.md, Matt Pocock skills, Graft codebase graph, Vitest/Playwright. Orchestrates upstream tools, idempotent.",
"type": "module",
"main": "dist/index.js",
Expand Down
158 changes: 158 additions & 0 deletions src/adapters/tool-ignores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import path from 'node:path';
import { fileExists, readTextFile, safeWriteFile } from '../utils/fs.js';
import { readPackageManifest } from '../config/validation.js';

// Vendored skill sources ship third-party JS and Markdown that the project's own
// linters and formatters must not check. No ignore convention is shared across tools,
// so each tool below says where its ignores live and how to add to them.
const VENDORED_DIRS = ['.agents', '.claude'];

interface Project {
manifest: Record<string, any> | null;
has(dependency: string): boolean;
exists(file: string): boolean;
}

interface ToolIgnore {
/** The file holding this tool's ignores, when the project uses the tool. */
file(project: Project): string | undefined;
/** Vendored directories the file does not ignore yet. */
missing(text: string): string[];
/** The file with those directories ignored, or null when it cannot be edited safely. */
patch(text: string, dirs: string[]): string | null;
advice(file: string, dirs: string[]): string;
}

const configs = (base: string, extensions: string[]) => extensions.map((ext) => `${base}${ext}`);

/** Gitignore-syntax files: append the directories. */
function ignoreFile(name: string, used: (project: Project) => boolean): ToolIgnore {
return {
file: (project) => (used(project) ? name : undefined),
missing: (text) => {
const lines = text.split(/\r?\n/).map((line) => line.trim().replace(/^\/|\/(\*\*)?$/g, ''));
return VENDORED_DIRS.filter((dir) => !lines.includes(dir));
},
patch: (text, dirs) => {
const block = `# Vendored agent skills (aginit)\n${dirs.map((dir) => `${dir}/`).join('\n')}\n`;
return text.trim() ? `${text.trimEnd()}\n\n${block}` : block;
},
advice: (file, dirs) => `add ${dirs.map((dir) => `${dir}/`).join(' and ')} to ${file}`
};
}

const prettier = ignoreFile(
'.prettierignore',
(project) =>
project.has('prettier') ||
Boolean(project.manifest?.prettier) ||
[
...configs('.prettierrc', ['', '.json', '.json5', '.yaml', '.yml', '.toml']),
...configs('.prettierrc', ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts']),
...configs('prettier.config', ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
].some(project.exists)
);

// Legacy ESLint and oxlint read .eslintignore; flat-config ESLint does not.
const eslintIgnore = ignoreFile(
'.eslintignore',
(project) =>
project.has('oxlint') ||
Boolean(project.manifest?.eslintConfig) ||
configs('.eslintrc', ['', '.js', '.cjs', '.json', '.yaml', '.yml']).some(project.exists)
);

/** Flat ESLint config: extend a global ignores array if there is an unambiguous one,
* `globalIgnores([...])` or an object holding only `ignores`. */
const eslintFlat: ToolIgnore = {
file: (project) =>
configs('eslint.config', ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']).find(project.exists),
missing: (text) => VENDORED_DIRS.filter((dir) => !text.includes(`${dir}/`)),
patch: (text, dirs) => {
const match =
/globalIgnores\(\s*\[/.exec(text) ??
/\{\s*ignores\s*:\s*\[(?=[^[\]]*\]\s*,?\s*\})/.exec(text);
if (!match) return null;
const at = match.index + match[0].length;
const rest = text.slice(at);
const quote =
/^\s*(['"`])/.exec(rest)?.[1] ??
((text.match(/"/g)?.length ?? 0) > (text.match(/'/g)?.length ?? 0) ? '"' : "'");
const entries = dirs.map((dir) => `${quote}${dir}/**${quote}`);
const indent = /^(\s*\n[ \t]*)/.exec(rest)?.[1];
const insertion = indent
? entries.map((entry) => `${indent}${entry},`).join('')
: entries.join(', ') + (/^\s*\]/.test(rest) ? '' : ', ');
return text.slice(0, at) + insertion + rest;
},
advice: (file, dirs) =>
`add { ignores: [${dirs.map((dir) => `'${dir}/**'`).join(', ')}] } to the exported array in ${file}`
};

/** Biome 2.2+: force-ignore the folders through files.includes. */
const biome: ToolIgnore = {
file: (project) => ['biome.json', 'biome.jsonc'].find(project.exists),
missing: (text) => VENDORED_DIRS.filter((dir) => !text.includes(`**/${dir}`)),
patch: (text, dirs) => {
let config: Record<string, any>;
try {
config = JSON.parse(text); // a biome.jsonc with comments gets advice instead
} catch {
return null;
}
const includes = config.files?.includes ?? ['**'];
if (!Array.isArray(includes)) return null;
config.files = {
...config.files,
includes: [...includes, ...dirs.map((dir) => `!!**/${dir}`)]
};
const json = JSON.stringify(config, null, /\n([ \t]+)"/.exec(text)?.[1] ?? 2) + '\n';
// Like Biome's formatter, keep the array on one line when it fits in 80 columns.
return json.replace(/^([ \t]*"includes": )\[[^\]]*\]/m, (block, key: string) => {
const inline = `${key}[${config.files.includes.map((p: string) => JSON.stringify(p)).join(', ')}]`;
return inline.replace(/\t/g, ' ').length <= 80 ? inline : block;
});
},
advice: (file, dirs) =>
`add ${dirs.map((dir) => `"!!**/${dir}"`).join(', ')} to files.includes in ${file}`
};

const TOOLS = [prettier, eslintIgnore, eslintFlat, biome];

function pendingIgnores(targetDir: string) {
const manifest = readPackageManifest(targetDir);
const project: Project = {
manifest,
has: (name) => Boolean(manifest?.dependencies?.[name] || manifest?.devDependencies?.[name]),
exists: (file) => fileExists(path.join(targetDir, file))
};
return TOOLS.flatMap((tool) => {
const file = tool.file(project);
if (!file) return [];
const text = readTextFile(path.join(targetDir, file)) ?? '';
const missing = tool.missing(text);
return missing.length > 0 ? [{ tool, file, text, missing }] : [];
});
}

/** Keeps the project's linters and formatters off vendored skills. Returns the
* advice for configs that could not be edited safely. */
export function ignoreVendoredSkills(
targetDir: string,
options: { dryRun?: boolean; silent?: boolean } = {}
): string[] {
const { dryRun = false, silent = false } = options;
const advice: string[] = [];
for (const { tool, file, text, missing } of pendingIgnores(targetDir)) {
const patched = tool.patch(text, missing);
if (patched === null)
advice.push(`Linters check vendored skills: ${tool.advice(file, missing)}`);
else safeWriteFile(path.join(targetDir, file), patched, { overwrite: true, dryRun, silent });
}
return advice;
}

/** Doctor view: every config that still lets a linter or formatter see vendored skills. */
export function vendoredSkillsExposure(targetDir: string): string[] {
return pendingIgnores(targetDir).map(({ tool, file, missing }) => tool.advice(file, missing));
}
17 changes: 17 additions & 0 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
runAgentBrowserDiagnostics
} from '../adapters/agent-browser.js';
import { readProjectConfig } from '../config/validation.js';
import { vendoredSkillsExposure } from '../adapters/tool-ignores.js';
import { logger } from '../utils/logger.js';

interface CheckItem {
Expand Down Expand Up @@ -226,6 +227,22 @@ export async function runDoctor(targetDir: string = process.cwd()): Promise<void
: 'Run `aginit init` or `npx skills add ...` to install skills'
});

// 9a. Vendored skills must stay out of the project's linters and formatters
if (installedSkills.length > 0) {
const exposure = vendoredSkillsExposure(targetDir);
checks.push({
name: 'Lint/format ignores for vendored skills',
category: 'Project Config',
ok: exposure.length === 0,
detail:
exposure.length === 0
? 'Detected linters and formatters skip .agents/ and .claude/'
: 'Project linters or formatters will check vendored skill files',
suggestion:
exposure.length === 0 ? undefined : `Run \`aginit update\`, or ${exposure.join('; ')}`
});
}

// 9b. Claude Code: skills linked into .claude/skills/ and AGENTS.md reachable
if (projectConfig !== null && claudeCodeEnabled(projectConfig)) {
if (!skillsDisabled && installedSkills.length > 0) {
Expand Down
5 changes: 5 additions & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createProject } from './new.js';
import { initCurrentDirectory } from './init.js';
import { runDoctor } from './doctor.js';
import { claudeCodeEnabled, ensureClaudeSkillLinks, updateSkills } from '../adapters/skills.js';
import { ignoreVendoredSkills } from '../adapters/tool-ignores.js';
import { setupClaudeMarkdown } from '../adapters/agents-md.js';
import { buildGraft } from '../adapters/graft.js';
import { readProjectConfig } from '../config/validation.js';
Expand Down Expand Up @@ -168,6 +169,10 @@ export function createCliProgram(): Command {
(config ? await ensureClaudeSkillLinks(targetDir, config, { dryRun: options.dryRun }) : true);
if (config && claudeCodeEnabled(config))
setupClaudeMarkdown(targetDir, { dryRun: options.dryRun });
if (config?.skills.sources.length !== 0) {
const advice = ignoreVendoredSkills(targetDir, { dryRun: options.dryRun, silent: true });
for (const line of advice) logger.warn(line);
}
const graftOk =
config?.codebase.graft === false
? true
Expand Down
5 changes: 5 additions & 0 deletions src/cli/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { setupGit } from '../adapters/git.js';
import { setupAgentsMarkdown, setupClaudeMarkdown } from '../adapters/agents-md.js';
import { claudeCodeEnabled, installAllSkills, skillAgents } from '../adapters/skills.js';
import { initGraft } from '../adapters/graft.js';
import { ignoreVendoredSkills } from '../adapters/tool-ignores.js';
import {
agentBrowserConfigured,
agentBrowserStubInstalled,
Expand Down Expand Up @@ -160,6 +161,10 @@ export async function createProject(
} else if (!silent) {
logger.dim('Skills installation skipped (--no-skills or empty configuration)');
}
if ((options.skills !== false && config.skills.sources.length > 0) || agentBrowserWanted) {
const advice = ignoreVendoredSkills(targetDir, { dryRun, silent: true });
if (!silent) for (const line of advice) logger.warn(line);
}

// Provision the agent-browser runtime the skill stub drives
if (agentBrowserWanted) {
Expand Down
38 changes: 35 additions & 3 deletions src/presets/web.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'node:path';
import { fileExists, safeWriteFile, writeJsonFile } from '../utils/fs.js';
import { fileExists, readTextFile, safeWriteFile, writeJsonFile } from '../utils/fs.js';
import { readPackageManifest } from '../config/validation.js';
import { runExecutable } from '../utils/shell.js';
import { PresetContext, PresetHandler } from './types.js';
Expand All @@ -8,6 +8,34 @@ import { setupAdr } from './adr.js';
import { scaffoldCommand } from '../adapters/package-manager.js';
import { addTestingDependencies, setupTesting } from './testing.js';

/** Plain tsc cannot resolve framework virtual modules or single-file components,
* so those frameworks get their own checker (and the package that provides it). */
function typecheckFor(
pkg: Record<string, any>,
targetDir: string,
freshNext: boolean
): { script: string; checker?: [string, string] } {
const has = (name: string) => Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
if (freshNext) return { script: 'next typegen && tsc --noEmit' };
if (has('astro')) return { script: 'astro check', checker: ['@astrojs/check', '^0.9.10'] };
if (has('nuxt')) return { script: 'nuxi typecheck', checker: ['vue-tsc', '^3.3.13'] };
if (has('@sveltejs/kit'))
return {
script: 'svelte-kit sync && svelte-check --tsconfig ./tsconfig.json',
checker: ['svelte-check', '^4.7.6']
};
if (has('svelte')) return { script: 'svelte-check', checker: ['svelte-check', '^4.7.6'] };
// A solution-style tsconfig (Vite templates) has no files of its own: only build mode checks
// the referenced projects.
const build = /"references"\s*:/.test(readTextFile(path.join(targetDir, 'tsconfig.json')) || '');
if (has('vue'))
return {
script: build ? 'vue-tsc -b --noEmit' : 'vue-tsc --noEmit',
checker: ['vue-tsc', '^3.3.13']
};
return { script: build ? 'tsc -b --noEmit' : 'tsc --noEmit' };
}

export const webPreset: PresetHandler = {
name: 'web',
description:
Expand Down Expand Up @@ -55,8 +83,12 @@ export const webPreset: PresetHandler = {
);
addTestingDependencies(pkg, config.browser.playwright);
if (fileExists(path.join(targetDir, 'tsconfig.json'))) {
pkg.scripts.typecheck ??=
fresh && framework === 'next' ? 'next typegen && tsc --noEmit' : 'tsc --noEmit';
if (pkg.scripts.typecheck === undefined) {
const { script, checker } = typecheckFor(pkg, targetDir, fresh && framework === 'next');
pkg.scripts.typecheck = script;
if (checker && !pkg.dependencies?.[checker[0]] && !pkg.devDependencies[checker[0]])
pkg.devDependencies[checker[0]] = checker[1];
}
if (!pkg.dependencies?.typescript && !pkg.devDependencies.typescript)
pkg.devDependencies.typescript = '^5.6.3';
}
Expand Down
52 changes: 52 additions & 0 deletions tests/presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,58 @@ describe('Preset Registry', () => {
expect(fs.existsSync(path.join(npmDir, 'pnpm-workspace.yaml'))).toBe(false);
});

async function scaffoldExisting(pkg: Record<string, any>, tsconfig: object = {}) {
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(pkg));
fs.writeFileSync(path.join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig));
await getPreset('web').scaffold({
targetDir: tmpDir,
projectName: 'existing',
config: getDefaultConfig('existing', 'web', { packageManager: 'bun' }),
silent: true
});
return JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'));
}

it.each([
[{ astro: '^5.0.0' }, 'astro check', '@astrojs/check'],
[{ nuxt: '^4.0.0' }, 'nuxi typecheck', 'vue-tsc'],
[
{ '@sveltejs/kit': '^2.0.0', svelte: '^5.0.0' },
'svelte-kit sync && svelte-check --tsconfig ./tsconfig.json',
'svelte-check'
],
[{ svelte: '^5.0.0' }, 'svelte-check', 'svelte-check'],
[{ vue: '^3.0.0' }, 'vue-tsc --noEmit', 'vue-tsc']
])('types an existing %o project with its framework checker', async (deps, script, checker) => {
const pkg = await scaffoldExisting({ name: 'existing', scripts: {}, dependencies: deps });
expect(pkg.scripts.typecheck).toBe(script);
expect(pkg.devDependencies[checker]).toBeTruthy();
});

it('keeps an existing typecheck script and the checker the project already pins', async () => {
const pkg = await scaffoldExisting({
name: 'existing',
scripts: { typecheck: 'astro check --minimumSeverity warning' },
dependencies: { astro: '^5.0.0', '@astrojs/check': '0.9.4' }
});
expect(pkg.scripts.typecheck).toBe('astro check --minimumSeverity warning');
expect(pkg.dependencies['@astrojs/check']).toBe('0.9.4');
expect(pkg.devDependencies['@astrojs/check']).toBeUndefined();
});

it('typechecks referenced projects of a solution-style tsconfig in build mode', async () => {
const pkg = await scaffoldExisting(
{ name: 'existing', scripts: {}, dependencies: { react: '^19.0.0' } },
{ files: [], references: [{ path: './tsconfig.app.json' }] }
);
expect(pkg.scripts.typecheck).toBe('tsc -b --noEmit');
});

it('keeps plain tsc for projects without a framework checker', async () => {
const pkg = await scaffoldExisting({ name: 'existing', scripts: {} });
expect(pkg.scripts.typecheck).toBe('tsc --noEmit');
});

it('generates pnpm-workspace.yaml only when packageManager is pnpm for cli preset', async () => {
const cli = getPreset('cli');

Expand Down
Loading
Loading