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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,36 @@ npx create-packkit --preset full my-pkg --pm pnpm

Then `cd`, and you already have a working project — `build`, `test`, and `lint` all pass out of the box.

## Create the repo, not just the folder

Packkit can create the remote and push the first commit, so you don't have to make an empty repo in a browser first:

```sh
# create it on GitHub (private) and push
npx create-packkit ts-lib my-lib --github

# public instead
npx create-packkit ts-lib my-lib --github --public

# any other host — GitLab, Bitbucket, Gitea, self-hosted
npx create-packkit ts-lib my-lib --git-remote git@bitbucket.org:me/my-lib.git
```

`--github` shells out to the [GitHub CLI](https://cli.github.com), so **Packkit never asks for, reads, or stores a token** — `gh` already holds your credentials. Created repos are **private unless you pass `--public`**.

This also fixes your links: the repository URL is baked into `package.json` and the README's CI badges when the files are generated, so letting Packkit resolve it up front means the badges point somewhere real from the first commit.

### Scaffolding into a repo you already have

Already cloned an empty repo, or started some work? `--merge` scaffolds around what's there:

```sh
git clone git@github.com:me/my-lib.git && cd my-lib
npx create-packkit ts-lib my-lib --here --merge
```

**Existing files are never overwritten.** Anything that collides is left alone and reported, so you can diff at your leisure. (A directory containing only `.git` counts as empty — a fresh clone scaffolds without needing `--merge` at all.)

## Or configure it on the web

No install needed: **[danmat.github.io/create-packkit](https://danmat.github.io/create-packkit/)** — tick the options, preview the file tree, and **download a zip** (or copy the equivalent `npx create-packkit` command). Everything runs in your browser.
Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ Planned and considered features for Packkit. Not commitments — a backlog to pu
- [x] ~~**Share a config as a URL** — encode the selection in the query string.~~ **Shipped (2.3).** "Share link" button; the diff-from-defaults is encoded and restored on load.
- [x] ~~**More service frameworks** — Fastify / Express alongside Hono.~~ **Shipped (2.4).** `--server <hono|fastify|express>`; framework-aware app/server/test.
- [x] ~~**Postinstall doctor** — check the local Node / package-manager versions match `engines`, warn if not.~~ **Shipped (2.5).** `--doctor`; `npm run doctor` (+ postinstall for private projects), warn-only.
- [ ] **Create the repo, not just the folder** — `--github [owner/name]` provisions the remote and pushes, delegating to the `gh` CLI so Packkit never handles a token (`glab` later); `--git-remote <url>` is the universal escape hatch for Bitbucket/Gitea/self-hosted. Opt-in, **private by default**. Also fixes badge accuracy: the repo URL is currently typed by hand and feeds `package.json` links + README badges, so deriving it from `owner/name` **before** generating makes existing output correct. Everything Packkit already emits — CI workflows, release automation, badges — is inert until a remote exists.
- [ ] **Scaffold into a non-empty directory** — `--here` aborts outright when the target has *any* entry, including a bare `.git/`. So the common flow (create empty repo → clone → scaffold) fails on the clone, which is what forces the backup/move/restore dance. Add a merge mode: classify existing files, write what's absent, skip or `.packkit-new` what collides, never clobber.
- [x] ~~**Create the repo, not just the folder**~~**Shipped (2.8).** `--github` provisions the remote and pushes via the `gh` CLI, so Packkit never handles a token (`glab` later); `--git-remote <url>` covers Bitbucket/Gitea/self-hosted. Opt-in, **private** unless `--public`. Resolving the URL *before* generating also fixes badge accuracy — `package.json` links and CI badges now point somewhere real from the first commit. Exposed over MCP too (`github`, `public`).
- [x] ~~**Scaffold into a non-empty directory**~~**Shipped (2.8).** `--merge` writes what's absent and never overwrites; collisions are kept and reported. A directory holding only `.git` now counts as empty, so the create → clone → scaffold flow works without any flag — that abort was what forced the backup/move/restore dance.
- [ ] **Vue/Svelte app scaffolds with a router** — the app targets are minimal SPAs; add vue-router / SvelteKit-style routing (plus a React Router option).
- [ ] **Multiple entry points** — `exports` subpaths (e.g. `./utils`) with per-entry builds (tsup multi-entry).

Expand Down
4 changes: 2 additions & 2 deletions mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "packkit-mcp",
"version": "0.1.3",
"version": "0.2.0",
"mcpName": "io.github.DanMat/packkit-mcp",
"description": "MCP server for Packkit — let AI agents scaffold modern npm packages, CLIs, services, and apps as a native tool.",
"type": "module",
Expand Down Expand Up @@ -34,6 +34,6 @@
"homepage": "https://danmat.github.io/create-packkit/",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"create-packkit": "^2.7.0"
"create-packkit": "^2.8.0"
}
}
71 changes: 49 additions & 22 deletions mcp/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@
// Packkit MCP server — exposes Packkit scaffolding as Model Context Protocol
// tools so agents (Claude Desktop, Cursor, etc.) can generate projects natively.

import { mkdir, writeFile } from 'node:fs/promises';
import { existsSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { join, resolve } from 'node:path';
import {
writeProject,
existingEntries,
gitInit,
installDeps,
hasCommand,
githubLogin,
createGithubRepo,
commitAll,
} from 'create-packkit/scaffold';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
Expand Down Expand Up @@ -57,6 +64,9 @@ const TOOLS = [
directory: { type: 'string', description: 'Parent directory to create the project in (default: current working directory)' },
install: { type: 'boolean', description: 'Install dependencies (default false)' },
git: { type: 'boolean', description: 'git init + initial commit (default false)' },
merge: { type: 'boolean', description: 'Scaffold into a non-empty directory. Existing files are never overwritten — colliding ones are skipped and reported. Use this when the target is an already-cloned repo.' },
github: { type: 'boolean', description: 'Create the repository on GitHub and push to it, using the `gh` CLI. Requires git. Private unless "public" is set.' },
public: { type: 'boolean', description: 'When creating the repository, make it public (default: private)' },
},
required: ['name'],
},
Expand All @@ -66,19 +76,11 @@ const TOOLS = [
const text = (t) => ({ content: [{ type: 'text', text: t }] });
const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true });

async function writeProject(targetDir, files) {
for (const [rel, contents] of Object.entries(files)) {
const full = join(targetDir, rel);
await mkdir(dirname(full), { recursive: true });
await writeFile(full, contents);
}
}

function fileTree(files) {
return Object.keys(files).sort().map((p) => ` ${p}`).join('\n');
}

const server = new Server({ name: 'packkit', version: '0.1.3' }, { capabilities: { tools: {} } });
const server = new Server({ name: 'packkit', version: '0.2.0' }, { capabilities: { tools: {} } });

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));

Expand All @@ -99,25 +101,50 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (!config.name) return fail('A "name" is required.');
const parent = args.directory ? resolve(args.directory) : process.cwd();
const targetDir = join(parent, config.name);
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
return fail(`Target directory "${targetDir}" is not empty.`);
const occupied = existingEntries(targetDir);
if (occupied.length && !args.merge) {
return fail(
`Target directory "${targetDir}" is not empty (${occupied.slice(0, 4).join(', ')}). ` +
'Pass merge: true to scaffold around the existing files — they are never overwritten.',
);
}

// Creating the repo has to be settled before generating: the repository
// URL is baked into package.json links and README badges.
let slug = null;
if (args.github) {
if (!args.git) return fail('github: true also needs git: true — there must be a commit to push.');
if (!hasCommand('gh')) return fail('github: true needs the GitHub CLI (https://cli.github.com).');
const login = githubLogin();
if (!login) return fail('github: true needs an authenticated GitHub CLI. Run: gh auth login');
slug = `${login}/${config.name}`;
config.repo = `https://github.com/${slug}`;
}

const { files, summary } = generate(config);
await writeProject(targetDir, files);
const { written, skipped } = await writeProject(targetDir, files, { merge: !!args.merge });

const steps = [];
if (skipped.length) steps.push(`kept ${skipped.length} existing file(s): ${skipped.join(', ')}`);
if (args.git) {
spawnSync('git', ['init', '--quiet'], { cwd: targetDir });
spawnSync('git', ['add', '-A'], { cwd: targetDir });
spawnSync('git', ['commit', '-m', 'Initial commit from Packkit', '--quiet'], { cwd: targetDir });
gitInit(targetDir);
steps.push('git initialized');
}
if (args.install) {
const ok = spawnSync(config.packageManager, ['install'], { cwd: targetDir }).status === 0;
steps.push(ok ? 'dependencies installed' : 'install failed (run it manually)');
steps.push(installDeps(config.packageManager, targetDir) ? 'dependencies installed' : 'install failed (run it manually)');
}
if (slug) {
if (args.install) commitAll(targetDir, 'Add lockfile');
const res = createGithubRepo({
slug,
description: config.description,
private: !args.public,
cwd: targetDir,
});
steps.push(res.ok ? `pushed to ${config.repo}` : `repo creation failed: ${res.error}`);
}
return text(
`Created ${summary.name} at ${targetDir}\n${summary.fileCount} files · ${summary.stack.join(' · ')}` +
`Created ${summary.name} at ${targetDir}\n${written.length} files · ${summary.stack.join(' · ')}` +
(steps.length ? `\n${steps.join('; ')}` : ''),
);
}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"exports": {
".": "./src/core/index.js",
"./core": "./src/core/index.js",
"./cli": "./src/cli/index.js"
"./cli": "./src/cli/index.js",
"./scaffold": "./src/cli/write.js"
},
"files": [
"bin",
Expand Down
26 changes: 26 additions & 0 deletions scripts/sync-mcp-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,29 @@ if (next !== src) {
console.log(
changed.length ? `Synced to ${version}: ${changed.join(', ')}` : `Already in sync at ${version}.`,
);

// packkit-mcp imports from create-packkit (`create-packkit/scaffold`), so a
// release whose version falls outside the range mcp declares would publish an
// MCP server that cannot install. Caught here — before anything is published —
// rather than as a confusing resolution error at the end of the release.
const range = pkg.dependencies?.['create-packkit'];
const rootVersion = JSON.parse(readFileSync('package.json', 'utf8')).version;
if (range && !satisfiesCaret(range, rootVersion)) {
console.error(
`\npackkit-mcp requires create-packkit "${range}", but this repo is at ${rootVersion}.\n` +
`The next release must be large enough to satisfy it (a minor bump if the floor is a new minor),\n` +
`or lower the range in mcp/package.json.`,
);
process.exit(1);
}

/** Minimal `^X.Y.Z` check — the only range style this repo uses. */
function satisfiesCaret(range, version) {
const m = /^\^?(\d+)\.(\d+)\.(\d+)/.exec(range);
const v = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
if (!m || !v) return true; // unrecognised range — don't block the release
const [, rMaj, rMin, rPat] = m.map(Number);
const [, vMaj, vMin, vPat] = v.map(Number);
if (vMaj !== rMaj) return false;
return vMin > rMin || (vMin === rMin && vPat >= rPat);
}
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "io.github.DanMat/packkit-mcp",
"title": "Packkit",
"description": "Scaffold modern npm packages, CLIs, services and apps as a native tool for AI agents.",
"version": "0.1.3",
"version": "0.2.0",
"repository": {
"url": "https://github.com/DanMat/create-packkit",
"source": "github",
Expand All @@ -14,7 +14,7 @@
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "packkit-mcp",
"version": "0.1.3",
"version": "0.2.0",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
Expand Down
10 changes: 10 additions & 0 deletions src/cli/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export function parseCliArgs(argv) {
yes: { type: 'boolean', short: 'y' },
recommended: { type: 'boolean' },
here: { type: 'boolean' },
merge: { type: 'boolean' },
github: { type: 'boolean' },
'git-remote': { type: 'string' },
public: { type: 'boolean' },
'no-install': { type: 'boolean' },
'no-git': { type: 'boolean' },
minify: { type: 'boolean' },
Expand Down Expand Up @@ -104,6 +108,12 @@ export function parseCliArgs(argv) {
from: values.from,
name,
here: !!values.here,
merge: !!values.merge,
github: !!values.github,
gitRemote: values['git-remote'] || null,
// Publishing code outward is opt-in, and private is the safe default — a
// wrong guess here leaks source, so --public must be asked for explicitly.
private: !values.public,
yes: !!values.yes || !!values.recommended,
hasConfigFlags,
install: !values['no-install'],
Expand Down
Loading