Framework declarativo para extensões do VSCode.
O TypeScript é a fonte única de verdade; o package.json é derivado dele.
O único build que pega um typo numa expressão when.
Português | English
import * as vscode from "vscode";
import { Extension, Command, Config, Watch } from "@sigilkit/core";
@Extension({ prefix: "hello" })
export class HelloExtension {
@Config({ description: "Texto exibido na saudação" })
accessor greeting: string = "Olá";
@Command({ title: "Say hello", category: "Hello", keybinding: "ctrl+alt+h" })
sayHello() {
vscode.window.showInformationMessage(`${this.greeting}!`);
}
@Watch("greeting")
onGreetingChanged(next: string, prev: string) {}
}Nenhuma linha de contributes é escrita à mão. sigil build gera:
- o bloco
contributesnopackage.json(merge — chaves não gerenciadas são preservadas); src/.generated/wire.ts— oactivate()real, que faz o join entre as chaves emitidas pelo compilador e os handlers registrados em runtime, e lança erro se faltar handler;src/.generated/config.d.ts— tipos por chave:getConfig("hello.retries")→number, com autocomplete; chave fora do registro retornaunknown.
Renomear um comando e esquecer o manifesto deixa de ser um comando fantasma —
vira erro de build com posição no arquivo. Um typo numa expressão when,
que falharia em silêncio para sempre, vira um erro de build (SIGIL1018) com
caret na linha.
Hot reload no workbench do sigil sim --ui: um comando executa, o handler é
editado, e o comportamento novo já vale — sem F5, sem abrir o VSCode:
- Uma fonte de verdade — identidade (ids, títulos, schemas) sai da AST em build time; comportamento (handlers) sai do registry em runtime; o join por chave estável é verificado nas duas pontas.
- Erros altos, nunca silêncio — handler ausente lança na ativação; exceção em comando vira log com stack + notificação "Abrir logs"; API não simulada no teste lança erro descritivo.
- Dev loop de segundos, não de F5 — quatro marchas: watch incremental, simulador com REPL, workbench visual no browser e VSCode real com hot swap.
- Testável por padrão —
@sigilkit/testativa o bundle real da extensão sem extension host; os exemplos e o próprio tutorial rodam no CI. - Web-ready e minificação-safe — runtime sem
node:*(funciona no vscode.dev) e join porSymbol.metadata(sem--keep-names).
O vizinho mais próximo é o reactive-vscode,
que ataca o runtime: reatividade estilo Vue por cima da API de eventos e
disposables. O sigil ataca a identidade: manifesto, activationEvents,
tipos de config e expressões when derivados do código e verificados no
build. As teses são complementares — a pergunta que decide é qual é a sua dor:
ergonomia de runtime, ou manter package.json, schema e when sincronizados
na mão. E a parte que nenhuma outra ferramenta (nem o próprio VSCode) oferece
é estrutural: validar when/enablement em build time exige ver as context
keys declaradas E as expressões ao mesmo tempo — só quem deriva o manifesto
do código tem os dois lados.
Os pacotes estão no npm sob o escopo @sigilkit:
npm create sigil minha-extensao
cd minha-extensao && npm install && npm run build
# abra no VSCode e aperte F5 — ou: npx sigil sim --ui .(--template=react-webview gera um painel React com o protocolo tipado
pronto. Sem o npm create: npm i -D @sigilkit/cli && npx sigil init.)
O projeto nasce pronto para agentes de IA: um AGENTS.md (o manual do
sigil — regras de ouro, API, loop de verificação) e o servidor MCP do
sigil já registrado (.mcp.json para o Claude Code, .vscode/mcp.json
para o Copilot). O agente ganha quatro tools — sigil_check (diagnósticos
estruturados com arquivo/linha), sigil_build, sigil_probe (executa a
extensão numa sessão viva do simulador e rebuilda sozinho quando o código
muda) e sigil_docs — e passa a editar, testar e provar que funciona sem
abrir o VSCode. É o framework dando à IA o loop de feedback que a API crua
nunca deu.
Siga o tutorial: sua primeira extensão em 5 minutos —
comando, config, status bar, watch, aba de opções e .vsix, sem abrir o
VSCode. O teste tests/tutorial.test.ts garante que ele nunca apodrece.
Neste monorepo:
npm install && npm run build # compila os quatro pacotes
npm test # unidade + simulador + E2E do CLI| Decorator | Em | O que declara |
|---|---|---|
@Extension({ prefix?, settings? }) |
classe | a extensão; settings: true gera a aba de opções (<prefix>.configure) |
@Command({ title, id?, keybinding?, menus?, enablement?, progress? }) |
método | comando + keybindings + menus; id fixa o id público independente do nome do método; progress envolve em withProgress (token é o último argumento) |
@Config({ description?, ... }) |
accessor | configuração — tipo, default e enum saem da declaração TS |
@Watch("chave") |
método | reação a mudança de config |
@Activate / @Deactivate |
método | lifecycle |
@StatusBar({ alignment?, command? }) |
accessor | item na status bar; atribuir ao accessor atualiza o texto |
@On("ns.evento", { debounce? }) |
método | evento da API com auto-dispose |
@OnFile(glob, kind, { debounce? }) |
método | FileSystemWatcher declarativo |
@UriHandler() |
método | deep links vscode://… (+ activationEvent automático) |
@State("global" | "workspace") |
accessor | persistência em Memento, tipada — reatribua (this.x = [...]); mutação interna (push) não persiste |
@Secret() |
accessor | SecretStorage com cache síncrono |
@ContextKey() |
accessor | setContext ao atribuir — e habilita a validação de when |
@TreeView({ name, container?, when? }) + @TreeRoot/@TreeChildren/@TreeItem |
classe | view na sidebar com TreeDataProvider adaptado; when validado |
@Webview({ title, ui, location?, when? }) + @OnMessage/@OnRequest |
classe | painel, sidebar ou location: "dual" (uma classe serve os dois — post em broadcast, RPC responde a quem perguntou); shell HTML (CSP + nonce) e RPC tipado; when validado |
@OnOpen / @OnDispose |
método | ciclo de vida do painel/view — abre/fecha |
@Every(ms) |
método | timer com o ciclo certo: na @Extension vive da ativação à desativação; num @Webview, só enquanto o painel está aberto |
@Language({ id }) + @Hover/@Completion/@CodeLens/@Diagnostics/@CodeAction/@Definition/@References/@Rename/@Formatting/@Symbols/@InlayHints |
classe | providers de linguagem (+ onLanguage:* automático); @Formatting aceita retornar o documento formatado como string |
@TestController({ label }) + @TestDiscover/@TestRun |
classe | Testing API declarativa: descubra testes como nós simples e devolva o resultado por teste — Test Explorer completo sem tocar em TestItem/TestRun |
@ChatParticipant({ id, name }) + @ChatRequest/@ChatFollowups |
classe | participante de chat (@nome no Copilot Chat) |
@ChatCommand("fix", { description? }) |
método | slash command do participante — declarado no manifesto e roteado por request.command |
@LmTool({ description, referenceName? }) |
método | tool do agent mode — inputSchema DERIVADO do tipo do parâmetro; o Copilot invoca |
@McpServers({ label }) |
método | provedor de servidores MCP (retorne {label, command, args} ou {label, uri}) |
@InlineCompletion |
método | ghost text (InlineCompletionItemProvider) — retorne strings |
@CustomEditor({ id, filenamePattern, ui }) |
classe | editor custom sobre o shell de webview, com applyEdit undo-friendly |
Strings do manifesto aceitam %chaves% de localização: mantenha seu
package.nls.json e o build valida cada chave usada — inexistente é
SIGIL1020 com caret na linha.
O ponto de integração da era Copilot é a tool que o agent mode invoca — e o
contributes.languageModelTools exige um JSON Schema escrito à mão
duplicando um tipo TS, amarrado por string ao registerTool. No sigil:
interface BuscaInput {
/** o texto a procurar */
consulta: string;
estado?: "aberta" | "fechada";
max?: number;
}
@LmTool({ description: "Busca issues do projeto", referenceName: "issues" })
buscarIssues(input: BuscaInput): string { ... }O inputSchema sai do tipo (JSDoc vira description, união de literais
vira enum, opcional vira não-required, aliases resolvem via checker), o
registro e o join são do wire, e host.invokeTool("…") testa a tool no
simulador sem Copilot nenhum. Slash commands (@ChatCommand), ghost text
(@InlineCompletion), provedores MCP (@McpServers) e llm.agent() — o
loop de tool-calling sem boilerplate — completam a fornada. Tudo sobre API
estável, acessada dinamicamente: sem exigir @types/vscode novo, com erro
alto em host antigo.
@Language({ id: "markdown" })
export class MarkdownAssist {
@Hover() hover(doc, pos) { return new vscode.Hover("…"); }
@Completion({ triggerCharacters: ["("] }) complete(doc, pos) { /* … */ }
@Diagnostics({ on: "change" }) validate(doc) { return [/* Diagnostic[] */]; }
@CodeAction({ kinds: ["quickfix"] }) fixes(doc, range, ctx) { /* CodeAction[] */ }
@Formatting() format(doc) { return prettify(doc.getText()); } // string basta
@Definition() def(doc, pos) { /* Location */ }
}O sigil emite os activationEvents: onLanguage:<id> (gerencia só o subconjunto
onLanguage:* — o resto do array é seu), registra os providers com dispatch
dinâmico (hot-swappáveis) e cuida do ciclo de vida do DiagnosticCollection:
revalida em change/save/open e limpa no close.
@ChatParticipant({ id: "guru", name: "guru" })
export class Guru {
@ChatRequest()
async responder(request, ctx, stream, token) { stream.markdown("…"); }
}Entra em contributes.chatParticipants; a API de chat é acessada
dinamicamente — não exige @types/vscode novo de quem não usa chat, e em
hosts antigos o bind falha alto com mensagem clara.
@CustomEditor({ id: "caps", displayName: "CAPS", filenamePattern: "*.caps", ui: "./ui/editor.html" })
export class CapsEditor {
@OnMessage("gritar")
gritar(_v: unknown, editor: SigilEditorContext) {
void editor.applyEdit(editor.getText().toUpperCase()); // undo funciona (WorkspaceEdit)
}
}Handlers recebem o contexto do documento como segundo argumento; a UI recebe o
conteúdo no load e a cada mudança (onDocument em @sigilkit/core/ui).
A feature que só o sigil pode ter: o compilador vê as @ContextKey declaradas
e as expressões when/enablement. Token com o seu prefixo que não é uma
context key, view ou comando declarado → SIGIL1018 com caret na linha.
Sintaxe inválida (&&&, parênteses desbalanceados) → SIGIL1019.
@ContextKey() accessor pronto = false;
@Command({ title: "Sync", enablement: "hello.pronto" }) // ✓ validado
sync() { /* … */ }O mesmo princípio aplicado ao terceiro contrato stringly-typed do ecossistema:
sigil build gera um sigil-env.d.ts na pasta apontada pelo ui:, e o
acquireVsCodeApi() daquela pasta passa a aceitar só os tipos declarados
nos @OnMessage/@OnRequest da classe — com o shape do value derivado do
parâmetro do handler (Parameters<>): mudou o tipo no host, a UI vê na hora,
sem rebuild.
// ui/notes.js — JS puro com // @ts-check já basta
vscode.postMessage({ type: "add", value: "texto" }); // ✓ autocomplete em tudo
vscode.postMessage({ type: "addd", value: "x" }); // erro: Did you mean '"add"'?
vscode.postMessage({ type: "remove", value: "sete" }); // erro: onRemove espera numberOs helpers de @sigilkit/core/ui saem tipados pelo mesmo arquivo:
callHost("send", …) infere o retorno do handler, postToHost valida a
mensagem, onHostMessage recebe a união host→UI derivada do tipo do post —
typo em qualquer chave é erro de build. São só tipos — funciona com qualquer
bundler (ou nenhum): um app React/Vite inclui o arquivo no tsconfig dele e
ganha o mesmo contrato (sigil init --template=react-webview scaffolda tudo
pronto). Convenção
recomendada: uma pasta (com um tsconfig lib: DOM + checkJs) por webview —
examples/notes é a vitrine.
Além dos decorators, o @sigilkit/core traz a base que toda extensão reescreve:
- Logs —
log.info/warn/error/debug/tracesobreLogOutputChannel(nível controlado pelo usuário); funciona antes da ativação (buffer). - Erros nunca somem — todo comando/watch/webview/tree passa por
guard(): erro vira log com stack + notificação com botão "Abrir logs"; trees degradam para item de aviso. - HTTP —
http.get/post/…sobre o fetch global: JSON automático, timeout,HttpErrorcom status/corpo,http.fetchImpltrocável em teste; ehttp.send()quando você quer a resposta crua ({ status, headers, text, json() }) sem lançar em não-2xx. - Ponte entre classes —
registry.instance(MinhaExtensao)devolve a instância viva e tipada de qualquer classe gerenciada (do painel para a extensão, por exemplo); classe não gerenciada lança na hora. Eregistry.panel(MeuPainel)fala com o webview de outra classe sem strings:posttipado pelopost!da classe (envia se aberto,falsese fechado),open()eisOpen. - Recursos —
resources.readText/readJson/readBytespara arquivos empacotados (viaworkspace.fs, funciona no vscode.dev). - Editor como renderer —
editor.openText(conteudo, { language, beside })abre um documento virtual num editor real: a forma vscode-native de mostrar um payload, com highlight e folding do próprio tema. - RPC host↔UI —
@OnMessage(fire-and-forget) e@OnRequestrespondendo acallHost(type, value)com correlação automática. - Wizards e LLM —
prompt.text/pick/confirm/steps(ESC volta um passo) ellm.ask/streamsobre a Language Model API. - Aba de configurações pronta —
@Extension({ settings: true })gera o comando<prefix>.configurecom formulário derivado do schema das@Config.
Tem uma extensão existente? Não reescreva nada. Três passos:
"sigil": { "graft": true }no seupackage.json— o merge passa a preservar todo o seucontributesmanual, entrada por entrada;- escreva a primeira classe sigil (um comando novo basta);
- no SEU
activate(), uma linha:await sigilActivate(ctx)(importado de./.generated/wire).
O sigil build soma o manifesto derivado ao seu sem tocar no que é manual, e
os dois mundos convivem em runtime. Daí em diante a migração é um comando por
vez, no seu ritmo — cada um migrado ganha when validado, config tipada e
testabilidade no simulador. (Trade documentado: no enxerto, entrada gerenciada
que você REMOVER do código sai do manifesto à mão — sem substituição integral
não há como distinguir "manual" de "ex-gerenciada".)
| Comando | O que faz | Quando usar |
|---|---|---|
sigil build |
AST → IR → manifesto + wire + tipos (cache por hash do IR) | build e CI |
sigil check |
falha se o manifesto commitado está stale | guardião no CI |
sigil dev |
watch incremental (ts.createWatchProgram, rebuilds de ~3ms) |
terminal ao lado do editor |
sigil sim |
hot reload no simulador @sigilkit/test + REPL |
testar comportamento sem UI |
sigil sim --ui |
workbench visual no browser, estado ao vivo por SSE | ver palette, trees, configs e webviews reais |
sigil sandbox |
VSCode real e isolado com hot swap sem F5 | fidelidade total |
sigil sim re-ativa a extensão no simulador a cada salvamento, preservando
configs, e o REPL a exercita ao vivo: run hello.sayHello, set hello.greeting "Oi" (dispara @Watch), tree hello.tasks, msg, input, logs.
sigil sim --ui abre http://127.0.0.1:4400: command palette clicável,
trees com expansão, editor de configs, status bar, toasts, Output — e
webviews renderizadas de verdade em iframes com shim de acquireVsCodeApi;
showInputBox/showQuickPick viram modais na página. É um harness visual do
que o simulador modela, não um clone do VSCode — para fidelidade total, use o
sandbox.
Hot reload de UI nos dois modos: com "sigil": { "uiDev": "npm run dev:ui" } no package.json (o template React já vem assim), o sim e o
sandbox sobem o watch da sua UI junto e recarregam o painel aberto
quando o bundle muda — editar um .tsx reflete no painel sem F5, sem reabrir,
num comando só (npm run sim).
sigil sandbox baixa um VSCode isolado (user-data e extensões próprios,
zero poluição do seu) e conecta um companion por socket. A janela abre com a
pasta do projeto e uma notificação confirma a extensão carregada, com botão
que abre a palette já filtrada nos comandos dela — ela roda em modo de
desenvolvimento (como no F5), então não aparece na aba Extensions, e isso é
esperado. O watch decide pelo
hash do IR: corpo de método mudou → 🔥 hot swap (~3ms, sem reload de
janela — o companion recarrega o bundle e chama __sigilHydrate()); manifesto
mudou → reload de janela automático. Estado de instância zera no swap (como
Fast Refresh); configs e painéis abertos sobrevivem. Requer node_modules no
projeto (o bundle deixa @sigilkit/core externo para o registry ser singleton
entre swaps).
Simulador do subconjunto da API vscode que o sigil toca. Ativa o bundle
real interceptando require("vscode"), semeia os defaults do manifesto e
expõe sondas:
import { activateExtension } from "@sigilkit/test";
const host = await activateExtension({ projectDir: "examples/hello" });
await host.executeCommand("hello.sayHello");
host.infoMessages; // ["Olá!"]
host.configuration.set("hello.greeting", "Oi"); // simula Settings → dispara @Watch
await host.tree("hello.tasks").roots(); // nós da view
host.panel("hello.settings").receive({ type: "save", value: { /* … */ } });
await host.dispose();Fidelidade onde importa (semântica de affectsConfiguration, registro
duplicado lança, painel singleton) e honestidade nas bordas: API não simulada
lança erro descritivo em vez de undefined silencioso. O que o simulador não
cobre, o E2E cobre no host real: npm run test:e2e roda examples/hello via
@vscode/test-electron.
npm run package # dentro do projeto da extensãoRoda vsce package --no-dependencies (o bundle já embute @sigilkit/core). O
.vscodeignore gerado pelo sigil init exclui fonte/testes e deixa entrar
out/, ui/ e media/. O .vsix instala via "Install from VSIX…" ou
code --install-extension; publicar no Marketplace é vsce publish.
O sigil init já gera tudo assim; para projetos existentes:
target: ES2022,experimentalDecorators: false,useDefineForClassFields: true— decorators stage 3;@Config,@StatusBar,@State,@Secrete@ContextKeyexigemaccessor;"include": ["src", "src/.generated/**/*"]no tsconfig (globs do tsc não atravessam diretórios com ponto);- bundle esbuild com
--target=es2022(sem isso a sintaxe de decorator fica crua no bundle);--keep-namesnão é necessário — o join usaSymbol.metadata, com teste que ativa o bundle minificado para provar; engines.vscode >= 1.75; chat exige host ≥ 1.90 em runtime (não em@types).
| Pacote | Papel | Regra inviolável |
|---|---|---|
@sigilkit/core |
runtime — vai para o bundle da extensão | nunca importa typescript (R1) nem node:* (web-ready) |
@sigilkit/compiler |
build time — AST → IR → emitters | nunca importa vscode (R2); nunca executa código do usuário (R3) |
@sigilkit/cli |
orquestração e IO | emitters são puros; todo IO fica aqui (R4) |
@sigilkit/test |
simulador para testes | nunca importa vscode nem typescript |
As regras são testadas: tests/boundaries.test.ts extrai imports por AST e
falha o build se alguma for violada. A API inteira numa página está em
docs/reference.md (pinada por teste: export fora da
referência quebra o build — feita para RAG e contexto de agentes; há também
um llms.txt na raiz). O design completo — modelo de propriedade
(§4), IR, diagnósticos SIGIL1000–SIGIL1022, armadilhas — está em
docs/spec.md, com as erratas descobertas na implementação ao
final.
Cada um valida um perfil de DX, e todos têm testes com @sigilkit/test — o mesmo
padrão que uma extensão real usaria:
| Exemplo | Perfil | O que exercita |
|---|---|---|
| examples/counter | mínimo — 1 classe, 1 arquivo | prefix default, união → enum, min/max, keybinding com mac |
| examples/todos | TreeView interativa | container próprio na activity bar, estado + refresh via @Watch, menu view/item/context, when auto-escopado |
| examples/notes | Webview de sidebar | assets via asWebviewUri, RPC tipado com @OnRequest, estado que sobrevive a fechar/reabrir |
| examples/hello | kitchen sink | tudo junto — inclusive @Language — + E2E no extension host real |
| examples/restbench | React na UI — cliente REST completo, publicado no Marketplace | protocolo tipado consumido por React, RPC @OnRequest, @LmTool (o Copilot chama sua API), plataforma http com fetch stubado no teste, @State/@Secret/@ContextKey + enablement, zero import vscode |
| examples/pets | case de rewrite — o host do vscode-pets | 1.347 linhas de host viram ~260; 293 linhas de contributes viram 0; a UI dos bichinhos fica byte-idêntica (35 linhas de glue) |
npm test # unidade + simulador + E2E do CLI (inclui os exemplos)
npm run test:e2e # extension host real (baixa o VSCode na primeira vez)Camadas: fixtures com um caso por diagnóstico (asserção de código e linha
do caret), snapshots de IR/emitters, merge de package.json, testes de
fronteira R1–R4, E2E do CLI (init/build/check em cópias isoladas), o
simulador sobre o bundle real (inclusive minificado), o tutorial pinado, e
o caminho feliz no extension host via @vscode/test-electron. O CI roda tudo,
com sigil check como guardião de manifesto stale.
As três fases do spec (núcleo, robustez, UI) estão completas, mais o roadmap pós-spec: superfícies de linguagem/chat/editores, DX sobre a API de eventos e estado, plataforma de runtime, os quatro modos de desenvolvimento e o empacotamento. A tabela de decorators reflete o que está implementado e testado — hoje o sigil cobre declarativamente a grande maioria dos tipos de extensão do marketplace.
A fila pública está no ROADMAP.md — o sigil evolui por dogfood, e issues com fricções reais são o combustível.
Pré-1.0: a API pública pode mudar entre versões minor (0.x → 0.y), sempre
com nota no CHANGELOG e nas releases.
Os quatro pacotes versionam em lockstep — use sempre a mesma versão de
todos. A partir do 1.0.0, semver estrito.

