Skip to content

Repository files navigation

sigil

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.

npm @sigilkit CI REST Bench no Marketplace Licença MIT

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 contributes no package.json (merge — chaves não gerenciadas são preservadas);
  • src/.generated/wire.ts — o activate() 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 retorna unknown.

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.

Em 30 segundos

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:

hot reload no sigil sim --ui: comando executado, handler editado, comportamento novo sem F5

Por que sigil

  • 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/test ativa 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 por Symbol.metadata (sem --keep-names).

Como se compara

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.

Começando

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

Os decorators

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 modeinputSchema 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.

A era do agent mode: @LmTool

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.

Superfícies de linguagem, chat e editores

@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).

when validado no build

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 protocolo do webview, tipado

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 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 number

Os 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.

Plataforma de runtime

Além dos decorators, o @sigilkit/core traz a base que toda extensão reescreve:

  • Logslog.info/warn/error/debug/trace sobre LogOutputChannel (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.
  • HTTPhttp.get/post/… sobre o fetch global: JSON automático, timeout, HttpError com status/corpo, http.fetchImpl trocável em teste; e http.send() quando você quer a resposta crua ({ status, headers, text, json() }) sem lançar em não-2xx.
  • Ponte entre classesregistry.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. E registry.panel(MeuPainel) fala com o webview de outra classe sem strings: post tipado pelo post! da classe (envia se aberto, false se fechado), open() e isOpen.
  • Recursosresources.readText/readJson/readBytes para arquivos empacotados (via workspace.fs, funciona no vscode.dev).
  • Editor como renderereditor.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 @OnRequest respondendo a callHost(type, value) com correlação automática.
  • Wizards e LLMprompt.text/pick/confirm/steps (ESC volta um passo) e llm.ask/stream sobre a Language Model API.
  • Aba de configurações pronta@Extension({ settings: true }) gera o comando <prefix>.configure com formulário derivado do schema das @Config.

Adoção incremental — o modo enxerto

Tem uma extensão existente? Não reescreva nada. Três passos:

  1. "sigil": { "graft": true } no seu package.json — o merge passa a preservar todo o seu contributes manual, entrada por entrada;
  2. escreva a primeira classe sigil (um comando novo basta);
  3. 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".)

Modos de desenvolvimento

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.

workbench do sigil sim --ui com tree view, webview renderizada, command palette, configurações e status bar

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).

Testando sem o VSCode — @sigilkit/test

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.

Empacotando (.vsix)

npm run package      # dentro do projeto da extensão

Roda 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.

Requisitos do projeto

O sigil init já gera tudo assim; para projetos existentes:

  • target: ES2022, experimentalDecorators: false, useDefineForClassFields: true — decorators stage 3; @Config, @StatusBar, @State, @Secret e @ContextKey exigem accessor;
  • "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-names não é necessário — o join usa Symbol.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).

O monorepo

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 SIGIL1000SIGIL1022, armadilhas — está em docs/spec.md, com as erratas descobertas na implementação ao final.

Exemplos

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)

Testes

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.

Status

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.

Roadmap

A fila pública está no ROADMAP.md — o sigil evolui por dogfood, e issues com fricções reais são o combustível.

Estabilidade

Pré-1.0: a API pública pode mudar entre versões minor (0.x0.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.

Licença

MIT

About

Framework declarativo para extensões do VSCode — o TypeScript é a fonte única de verdade, o manifesto é derivado

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages