Skip to content

Repository files navigation

vsceasy octopus mascot

vsceasy

Build VS Code extensions fast. React UI + typed RPC bridge between extension and webview + zero-config build.

Pronounced "vee-see-easy" in English (VSC + easy) — or "visici" for Spanish speakers.

▶ Watch the 30-second promo

Status: v0.1 — React UI. Typed RPC bridge + file-based registry + scaffolding for panels, commands, menus, tree views, subpanels, status bars.

Install

Run with no install via bunx @vsceasy/cli … (or npx @vsceasy/cli …), or install the binary globally to use the short vsceasy command:

bun add -g @vsceasy/cli   # or: npm i -g @vsceasy/cli

# use globally
vsceasy --version

Quick start

bunx @vsceasy/cli create my-extension
# prompts to init git + install deps, then:
cd my-extension
bun run dev
# press F5 in VS Code to launch the Extension Development Host

Or with flags — --git / --install skip the post-scaffold prompts:

bunx @vsceasy/cli create \
  --name my-extension \
  --displayName "My Extension" \
  --description "Does cool things" \
  --publisher my-publisher \
  --type ui \
  --ui react \
  --git \
  --install

Extension types

create asks what kind of extension you're building (--type):

Type What you get
ui (default) React webview + typed RPC bridge. --preset full adds a sample panel; --preset minimal is empty.
language Language support — TextMate grammar, language-configuration.json, snippets, and an opt-in file-icon theme. No React.
empty Bare extension (activate/deactivate only). No UI, no language.
bunx @vsceasy/cli create --name toml --type language --extensions .toml

Non-generated contributions: contributes.extra.json

scripts/gen.ts regenerates the parts of package.json#contributes it owns (commands, keybindings, views) on every build. Anything it doesn't generate — languages, grammars, snippets, themes, iconThemes, walkthroughs — goes in a contributes.extra.json file at the project root and is deep-merged into package.json on bun run gen. The keys gen owns always win; everything else from contributes.extra.json is preserved. The language scaffold ships a populated contributes.extra.json wiring up its grammar, snippets and icons.

What you get

my-extension/
├── src/
│   ├── extension/
│   │   ├── extension.ts            # entry, command registration
│   │   └── panels/DashboardPanel.ts # webview panel + RPC handlers
│   ├── webview/
│   │   ├── App.tsx                 # React UI (typed RPC client)
│   │   ├── main.tsx
│   │   └── styles.css              # VS Code theme tokens
│   └── shared/
│       ├── api.ts                  # RPC contract (types both sides)
│       └── rpc.ts                  # bridge implementation
├── vite.config.ts                  # webview build
└── package.json                    # esbuild for extension, vite for UI

Command map

flowchart TD
    V["vsceasy"] --> CR["create<br/>scaffold project"]
    V --> UI_["ui features"]
    V --> DATA["data"]
    V --> OPS["ops"]
    UI_ --> P["panel add"]
    UI_ --> SP["subpanel add"]
    UI_ --> MN["menu add / edit"]
    UI_ --> TV["treeview add"]
    UI_ --> CMD["command add"]
    UI_ --> SB["statusBar add"]
    UI_ --> RPC["rpc add"]
    DATA --> DB["db init"]
    DATA --> MD["model add"]
    DATA --> CRUD["crud add"]
    OPS --> JOB["job add"]
    OPS --> HLP["helper add"]
    OPS --> TST["test setup"]
    OPS --> PUB["publish init"]
    OPS --> DOC["doctor"]
    OPS --> UPG["upgrade"]
Loading

Run vsceasy <group> <subcommand> --help for details on any command.

Typed RPC

Define the contract once:

// src/shared/api.ts
export interface DashboardApi {
  listFiles(pattern: string): Promise<string[]>;
}

Extension side — implement:

const handlers: DashboardApi = {
  async listFiles(pattern) {
    const uris = await vscode.workspace.findFiles(pattern);
    return uris.map(u => vscode.workspace.asRelativePath(u));
  },
};
createRpcServer(webviewTransport(panel.webview), handlers);

Webview side — call with full type inference:

const api = createRpcClient<DashboardApi>(vscodeApiTransport(vscode));
const files = await api.listFiles('**/*.ts');  // typed string[]

No manual postMessage. No string-typed message channels.

CLI commands

Structure: vsceasy <resource> <verb> [flags]. Every command runs interactively when flags are omitted (banner + per-param prompts) or fully scripted via flags.

New to the project? Run the guided wizard:

vsceasy wizard

It detects whether you're inside a vsceasy project — outside it walks you through create, inside it menus the common generators (panel, command, database, model, helper) and points you at the rest.

vsceasy
├── wizard              interactive guided flow (create or add features)
├── create              scaffold a new extension project
├── panel
│   └── add             new webview panel + optional typed RPC (opens in editor area)
├── menu
│   ├── add             new sidebar tree view (activity bar)
│   └── edit            add an item (panel / command / url / group)
├── command
│   └── add             new palette command (with optional menu entry + keybinding)
├── rpc
│   └── add             add a typed RPC method to a panel
├── statusBar
│   └── add             status bar item → command / panel / menu popup
├── subpanel
│   └── add             inline sidebar section (lives under a menu container)
├── treeview
│   └── add             data-driven tree view (getChildren/getTreeItem) under a menu
├── test
│   └── setup           Vitest config + sample test + vscode/RPC mock helpers
├── publish
│   └── init            marketplace preflight (README, CHANGELOG, icon, vsce ls)
├── helper
│   └── add             generate runtime helper (secrets | config | state | notifications)
├── job
│   └── add             recurring / event-triggered job (interval | dailyAt | on event | onFile)
├── db
│   └── init            scaffold project database (mini-ORM)  at src/helpers/db.ts
├── model
│   └── add             typed entity + repo under src/models/
├── crud
│   └── add             full CRUD scaffold (service + list panel + form panel + menu wire)
├── components
│   └── add             themed React UI library (Button/Input/Field/Card/List) for webviews
├── doctor              diagnose project + safe --fix
└── upgrade             sync framework-owned files from the bundled templates

Run vsceasy <resource> --help for verbs and vsceasy <resource> <verb> --help for params.

panel add

vsceasy panel add --name settings --title "Settings" --withApi yes

# start from a working UI instead of a blank component:
vsceasy panel add --name signup --template form       # inputs + save() RPC
vsceasy panel add --name items  --template list        # list + load() RPC
vsceasy panel add --name stats  --template dashboard   # stat cards + stats() RPC

Generates src/panels/<name>.ts + src/webview/panels/<name>/{App.tsx,main.tsx} + appends <Name>Api to src/shared/api.ts when withApi=yes.

--template (blank | form | list | dashboard, default blank) starts the panel from a working UI built with the shared component library (auto-generated on first use via components add) and wires the matching RPC method. Non-blank templates force --withApi on.

components add

vsceasy components add          # idempotent; --force to overwrite

Writes a theme-aware React component library (Button, Input, Field, Card, List + components.css) to src/webview/components/, styled with VS Code theme tokens (var(--vscode-*)). Import it from any webview: import { Button, Card, List } from '../../components'.

command add

vsceasy command add \
  --name doStuff \
  --title "Do Stuff" \
  --menuEntry main \
  --group "Actions" \
  --icon play \
  --keybinding "ctrl+shift+h" \
  --when "editorTextFocus && resourceLangId == typescript"

--when writes a VS Code when clause that controls palette enablement + auto-injects contributes.menus.commandPalette so the command is hidden when the context doesn't match. See when-clause contexts. For richer keybindings (mac override / when clause) edit the generated file:

keybinding: { key: 'ctrl+shift+h', mac: 'cmd+shift+h', when: 'editorTextFocus' }
// or an array
keybinding: ['ctrl+a', { key: 'ctrl+b', mac: 'cmd+b' }]

menu add

Sidebar tree view. Icon picker is searchable across 186+ codicons.

vsceasy menu add --name main --title "My Tools" --icon rocket

menu edit

Add an item to an existing menu — opens a panel, runs a command, opens a URL, or creates a collapsible group. Conditional prompts adapt to the chosen item kind.

vsceasy menu edit --name main --kind panel --panel dashboard --label "Dashboard" --icon window

rpc add

Extends src/shared/api.ts (creates the interface if missing) + inserts a handler stub into the panel (creates the rpc: block if missing). Auto-adds import type + definePanel<…Api> generic when needed.

vsceasy rpc add --panel dashboard --method getCount --params "limit: number" --returns "number"

statusBar add

Bind to a command, panel, or popup menu (QuickPick). Markdown tooltip available (Copilot/GitLens-style hover).

# existing command
vsceasy statusBar add --name buildBtn --text Build --bindTo command --command hello --icon tools

# panel (auto-wires <prefix>.open<Panel>)
vsceasy statusBar add --name openDash --text Dashboard --bindTo panel --panel dashboard

# bootstrap new command + status bar together
vsceasy statusBar add --name sync --text Sync --bindTo "create new command" --newCommandTitle "Run Sync"

# popup menu — interactive loop builds each item
vsceasy statusBar add --name tools --text Tools --bindTo menu

Rich markdown tooltip:

vsceasy statusBar add \
  --name copilotPro --text "Copilot Pro" --icon copilot \
  --bindTo panel --panel dashboard \
  --tooltipMarkdown "### Copilot Pro\n\n**18% used**\n\n[Upgrade](command:myext.openDashboard)"

subpanel add

Inline sidebar section (the GitLens / Copilot pattern — collapsible webview that lives inside a menu container). Each subpanel becomes a new section under the chosen activity-bar menu, stacked alongside the tree view and any other sibling subpanels.

vsceasy subpanel add --name welcome --menu main --title "Welcome" --withApi yes

Files:

  • src/subpanels/<name>.ts (defineSubpanel with menu: '<container>')
  • src/webview/subpanels/<name>/{App.tsx,main.tsx} (React bundle)
  • <Name>ViewApi appended to src/shared/api.ts when withApi=yes

Multiple subpanels can reference the same menu — they render as stacked collapsible sections in that container.

doctor

Diagnose engine, scripts, panels, RPC contract sync, menu refs, codicons, status bar refs, package.json contributes drift, .gitignore.

vsceasy doctor             # report
vsceasy doctor --fix=true  # auto-fix: missing RPC handler stubs, orphan menu items, .gitignore entries

upgrade

Sync framework-owned files (src/shared/vsceasy/*, scripts/gen.ts) from the bundled templates. Use after upgrading vsceasy.

vsceasy upgrade              # dry-run
vsceasy upgrade --apply=true # write + auto-run `bun run gen`

treeview add

Data-driven tree view inside an existing menu container. Lazy children via getChildren. Built-in dispatch for run / panel / command on click.

vsceasy treeview add --name explorer --menu main --title "Explorer"

Generates src/treeViews/<name>.ts. Refresh from anywhere: vscode.commands.executeCommand('<prefix>._tree.<name>.refresh').

test setup

Drops a vitest.config.ts, a sample src/__tests__/sample.test.ts, and src/__tests__/_helpers.ts with mockVscode(), mockContext(), and mockRpcPair<H>() for end-to-end handler tests. Also adds test / test:watch scripts + vitest devDep.

vsceasy test setup
bun install && bun run test

Example:

import { mockRpcPair } from './_helpers';
import type { DashboardApi } from '../shared/api';

const handlers: DashboardApi = { async listFiles() { return ['a.ts']; } };
const api = mockRpcPair<DashboardApi>(handlers);
expect(await api.listFiles('**/*.ts')).toEqual(['a.ts']);

job add

Recurring or event-triggered background work. Runtime handles registration, cleanup on deactivate, and error isolation. Optional --minIntervalMs throttles re-runs across triggers (persisted in context.globalState).

# every 60s, runs on startup
vsceasy job add --name sync --every 60s

# at 02:30 local time daily
vsceasy job add --name nightlyIndex --dailyAt 02:30

# on document save (with at-most-once-per-hour throttle)
vsceasy job add --name lint --on saveDocument --minIntervalMs 3600000

# on filesystem changes (create | change | delete)
vsceasy job add --name reindexMd --onFile "**/*.md"

# event triggers: startup | saveDocument | openDocument | changeActiveEditor | changeConfig

--every accepts 30s, 5m, 2h, 1d, or a raw ms number. Jobs run only while the extension host is active — for cross-session schedules use OS cron or GitHub Actions.

helper add

Generates typed wrappers into src/helpers/. Cuts boilerplate for the most-touched VS Code APIs + storage.

vsceasy helper add --kind secrets         # context.secrets — OS keychain
vsceasy helper add --kind config          # workspace.getConfiguration — typed
vsceasy helper add --kind state           # workspace + global mementos
vsceasy helper add --kind notifications   # toast + confirm + withProgress
vsceasy helper add --kind cache           # in-memory TTL + LRU + wrap()
vsceasy helper add --kind colorize        # scoped editor.tokenColorCustomizations
# For ORM use the dedicated commands: `vsceasy db init` + `vsceasy model add`

The colorize helper writes theme-independent token colors scoped to a single TextMate scope (e.g. a language's source.x), so only that language is recolored — other languages keep the user's theme. create --type language wires it automatically (auto-apply on activate behind a <prefix>.colorize opt-out setting + Apply/Remove Colors commands). For secrets and state, wire on activate:

import { initSecrets } from './helpers/secrets';
initSecrets(context);

db init + model add (mini-ORM)

Typed entities, CRUD, transactions. Workflow:

# 1. one-time per project — creates src/helpers/db.ts
vsceasy db init                         # filesystem JSON under context.storageUri/db/
vsceasy db init --provider global       # or globalStorageUri (user-wide)

# 2. add models interactively — type `name:type` per line, empty to finish
vsceasy model add --name User
#   field 1: id:string!         (! = primary key)
#   field 2: name:string
#   field 3: email?:string@     (? = optional, @ = indexed)
#   field 4: createdAt:number
#   field 5:                    (enter to finish)

# 3. or in one shot
vsceasy model add --name Post --fields "id:string!,userId:string@,title:string,body:string"

Wire on activate (src/extension/extension.ts):

import { initDb } from '../helpers/db';
initDb(context);

Use:

import { UsersRepo } from '../models/User';

await UsersRepo().insert({ id: 'u1', name: 'Jairo', email: 'j@x.io', createdAt: Date.now() });
await UsersRepo().update('u1', { name: 'Jairo F' });
const u = await UsersRepo().findById('u1');
const recent = await UsersRepo().findMany({ orderBy: 'createdAt:desc', limit: 10 });
const matches = await UsersRepo().findMany({ where: { email: { in: ['a@x.io', 'b@x.io'] } } });
await UsersRepo().deleteMany({ email: 'spam@x.io' });

// Atomic — rolls back on throw
import { db } from '../helpers/db';
import { Users } from '../models/User';
await db().transaction(async (tx) => {
  await tx(Users).insert({ id: 'u2', name: 'A', email: 'a@x.io', createdAt: Date.now() });
  await tx(Users).update('u1', { name: 'B' });
});

Operators: value (eq), { in: [...] }, { neq: value }. orderBy: 'field:asc' | 'field:desc' | 'field'.

Field-spec grammar (interactive loop + --fields):

  • name:type — required field
  • name?:type — optional
  • name:type! — primary key (defaults to id or first field)
  • name:type@ — indexed
  • combine: score:number!@

crud add (Rails-style scaffold)

End-to-end CRUD UI for a model. Reads src/models/X.ts, generates:

src/services/UserService.ts        ← business logic between RPC and repo
src/panels/usersList.ts            ← list panel (RPC: list, delete, openForm)
src/panels/userForm.ts             ← form panel (RPC: get, save, cancel)
src/webview/panels/usersList/      ← React table w/ Edit + Delete buttons
src/webview/panels/userForm/       ← React form, inputs inferred from TS types
src/shared/api.ts                  ← appends UsersListApi + UserFormApi
src/menus/users.ts (optional)      ← if --menu new:users

Inputs auto-inferred:

  • string<input type="text">
  • number<input type="number">
  • boolean<input type="checkbox">
  • Date<input type="date">
  • 'a' | 'b' | 'c'<select> with options
  • null | undefined unions stripped before inference
# Interactive — picks menu policy (none | existing | new)
vsceasy crud add --model User

# Scripted
vsceasy crud add --model User --menu new:users
vsceasy crud add --model Post --menu existing:settings
vsceasy crud add --model Note --menu "(no menu)"

Custom overrides — src/models/X.crud.ts

Optional file next to your model:

export default {
  title: 'People',                       // display label
  icon: 'person',                        // codicon for menu
  hidden: ['createdAt'],                 // strip from list + form
  order: ['name', 'email', 'role'],      // explicit field order
  fields: {
    name: { label: 'Full name' },
    bio:  { input: 'textarea', placeholder: 'Short bio…' },
    role: { input: 'select', options: ['admin', 'editor', 'viewer'] },
    email: { hideInList: true },         // visible in form, hidden in table
  },
};

Cache

import { createCache } from './helpers/cache';

const cache = createCache<User>({ ttlMs: 60_000, max: 200 });
const u = await cache.wrap(`user:${id}`, () => orm(User).findById(id));
await cache.refresh(`user:${id}`, () => orm(User).findById(id)); // force reload

Concurrent wrap() calls for the same key share one in-flight loader.

publish init

Marketplace preflight: writes README.md, CHANGELOG.md, an assets/icon.png placeholder, fills repository / categories / icon in package.json, and runs npx @vscode/vsce ls as a dry-pack.

vsceasy publish init
# fix any warnings, then:
npx @vscode/vsce package
npx @vscode/vsce publish

Project config (vsceasy.config.ts)

Auto-generated by vsceasy create. Persists per-project defaults so generators don't re-prompt them.

import type { VsceasyConfig } from 'vsceasy';

const config: VsceasyConfig = {
  publisher: 'my-publisher',
  commandPrefix: 'myExt',
  ui: 'react',
  defaultCategory: 'My Extension', // used by `command add` when --category is omitted
  defaultIcon: 'rocket',           // used by `menu add` when --icon is omitted
};

export default config;

Convention

Files in src/{panels,commands,menus,statusBars,subpanels}/*.ts are auto-discovered by bun run gen, which produces src/extension/_registry.ts and keeps package.json#contributes in sync. The framework's runtime (bootstrap(registry)) wires everything into VS Code on activation.

Architecture

See ARCHITECTURE.md.

License

MIT

Releases

Sponsor this project

Packages

Contributors

Languages