Communitygithub.com

jongio/skills

Un plugin de habilidades multi-agente para agentes de codificación de IA — GitHub Copilot, Claude y Codex — instalable con la CLI de skills (npx skills add). Incluye create-canvas-kit para construir extensiones de canvas de Copilot.

¿Qué es skills?

El conjunto de habilidades `jongio/skills` proporciona un plugin universal de habilidades para agentes de codificación de IA como GitHub Copilot, Claude y Codex, diseñado para ser instalado y administrado a través de la línea de comandos de skills (`npx skills add`). Simplifica la reutilización y el mantenimiento de habilidades a través de diferentes plataformas de agentes. Una de sus habilidades principales es `create-canvas-kit`, que te permite construir extensiones de lienzo (canvas) para aplicaciones de Copilot de una manera estructurada y eficiente. Este kit resuelve problemas comunes como la pérdida de estado, el repintado completo del DOM y la falta de coherencia temática, proporcionando una arquitectura basada en eventos, estado compartido y persistente, y un modelo de renderizado optimizado con Preact. Con este kit, tanto el agente como el usuario pueden interactuar con el mismo estado y los mismos manejadores de acciones, y los cambios de estado se transmiten automáticamente a través de SSE a todos los paneles abiertos, evitando la pérdida de foco o de datos en campos de entrada.

Compatible con✓Claude Code✓Codex CLI~Cursor
npx skills add jongio/skills

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Crear Aplicación Canvas

Una forma completa y preconfigurada de construir extensiones de canvas para aplicaciones Copilot. Existe porque los canvases hechos a mano tropiezan una y otra vez con los mismos problemas: un bucle de repintado con innerHTML que devora las pulsaciones de teclas, un estado que vive solo en un panel, estilos improvisados que ignoran el tema del anfitrión e iconos inconsistentes. El kit soluciona los cuatro.

Qué es un canvas (y cuándo construir uno)

Un canvas es una superficie interactiva que el agente abre en un panel lateral mediante open_canvas. Tanto el agente como el usuario actúan sobre el mismo estado a través de los mismos manejadores de acciones. Recurre a un canvas cuando el texto del chat o un diff no son suficientes: paneles de control en vivo, editores, hojas de cálculo, rastreadores, vistas kanban/tablero, previsualizaciones de documentos, flujos de trabajo específicos de una herramienta.

Si el usuario solo necesita una herramienta de agente no visual, crea una herramienta de extensión normal — no un canvas.

Nivel de renderizado — decide primero

NivelCuándo usarloCómo
Cadena HTML estáticaContenido de solo lectura o casi estático; sin entradas que protegerEl andamio estándar del runtime (extensions_manage scaffold kind:canvas).
Preact + htm + este kitCualquier cosa interactiva: entradas, actualizaciones en vivo, listas, formularios, estado compartidoEste kit. Opción por defecto para canvases reales.

La razón más importante para usar el kit: Preact hace diff del DOM, por lo que una inserción de estado en vivo desde el agente no interrumpe el foco, la posición del cursor ni el texto a medio escribir en una entrada. El patrón innerHTML = ... que usan la mayoría de los canvases tempranos repinta todo el árbol y pierde las pulsaciones de teclado en cada inserción. No hagas eso.

El modelo (léelo antes de programar)

extension.mjs   ── el ÚNICO archivo que importa el SDK de Copilot (adaptador delgado)
canvas.mjs      ── tu canvas: id, esquema, carga/guardado de estado, manejadores de acciones (sin SDK)
canvas-kit/     ── el kit (copiado textualmente; no editar)
web/index.html  ── shell: carga /kit/theme.css y ./app.mjs
web/app.mjs     ── tu vista Preact
  • El estado es compartido y duradero. Se almacena mediante una id de dominio resuelta a partir de la entrada abierta (resolveDomainId), no por instanceId — abre el mismo dominio en dos paneles y ambos muestran los mismos datos. La persistencia pasa por userStore(extName, file) → $COPILOT_HOME/extensions/<nombre>/artifacts/<dominio>.json.
  • El agente y la interfaz de usuario comparten manejadores. Una acción invocada por el agente y la misma acción invocada desde un botón ejecutan el manejador idéntico y producen la misma mutación de estado. Escribe la lógica una vez, en canvas.mjs.
  • Las actualizaciones en vivo son automáticas. El kit sirve GET /state, GET /events (SSE) y POST /action. mountCanvas los conecta; cada cambio de estado se propaga a todos los paneles abiertos.
  • server.mjs está libre de SDK por lo que todo el runtime se puede probar con Node HTTP simple (consulta test/http.test.mjs). Mantén las llamadas al SDK solo en extension.mjs.

Dos tipos de estado en la vista — mantenlos separados

  • Estado de dominio compartido llega a través de SSE y se vuelve a renderizar en cada inserción. Trátalo como solo lectura en la vista; cámbialo solo llamando a invoke(action, input).
  • Estado local de la interfaz de usuario (el valor de una entrada en borrador, la pestaña/filtro activo) vive en useState y nunca se debe enviar al servidor hasta que el usuario lo confirme.

Resultados de acciones y errores — cómo se muestran los fallos

Ambos llamadores (agente e IU) alcanzan el mismo manejador, y el runtime envuelve cada invocación en el mismo envoltorio, por lo tanto, modela los fallos deliberadamente:

  • Devuelve un objeto simple pequeño para éxito ({ id, status }, { count }). POST /action responde { ok: true, result }.
  • Lanza para un fallo grave (entrada incorrecta, id faltante). El runtime responde { ok: false, code, message } — HTTP 400 para un error conocido del kit (p. ej., una acción desconocida) y 500 para un throw del manejador — y el invoke de mountCanvas rechaza con un CanvasActionError que lleva ese code/message, por lo que el manejador de un botón puede usar try/catch. En el lado del agente, extension.mjs mapea el mismo error a un CanvasError. Un lanzamiento es mostrado, nunca un bloqueo.
  • Para un fallo esperado y transitorio (una obtención remota inestable en una consulta periódica en segundo plano), no lances — captúralo en state.error y devuelve para que la tarjeta de error se muestre mientras la consulta permanece silenciosa (consulta la plantilla de datos refresh).

Iconos — GitHub Lucide oficial, siempre

El kit distribuye el conjunto exacto de Lucide que github-app utiliza ([email protected], byte-idéntico). Úsalo para cada icono; no escribas SVGs a mano ni uses una CDN.

import { Icon } from "/kit/client.mjs";
html`<${Icon} name="circle-check" size=${16} />`

Contrato: los nombres están en kebab-case (circle-check, trash-2, list-todo). viewBox es fijo 0 0 24 24, stroke-width es 2 — nunca pases strokeWidth. Usa tamaños 12 · 14 · 16 · 20 · 24 · 32. Coincide con las elecciones de glifos propias de github-app cuando haya una correspondencia obvia (p. ej., open=circle-dot, done=circle-check, bookmark=bookmark). iconNames() / hasIcon(name) están disponibles si necesitas validar.

Tema — tokens Primer, con valores de respaldo

Enlaza /kit/theme.css y construye sobre sus clases ck-* (ck-card, ck-btn, ck-btn-primary, ck-input, ck-badge, ck-tabs, ck-status, ck-empty, …). Se asignan a los tokens Primer que el runtime inyecta en el documento canvas, cada uno con un valor de respaldo hardcodeado para GitHub-dark, por lo que un canvas se ve bien incluso si falta un token. Agrega CSS específico del canvas encima; no reestilices los primitivos.

Las insignias son genéricas. Usa las variantes semánticas ck-badge-success, ck-badge-accent, ck-badge-muted, ck-badge-danger, ck-badge-attention y mapea tus estados a ellas en la vista (la referencia hace open → success, decided → accent, parked → muted). No agregues nombres de clase de estado específicos de la aplicación al tema compartido.

Primitivas de carga y error (no las crees a mano):

  • ck-spinner — animación de giro; combínalo con un cargador Lucide, p. ej. html\<${Icon} name="loader-circle" class="ck-spinner" />``.
  • ck-skeleton — bloque de marcador de posición brillante para contenido que aún se está cargando.
  • ck-callout / ck-callout ck-error — una nota en línea / superficie de error para una obtención fallida, p. ej. html\<${Icon} name="circle-x" size=${16} />${state.error}``.

La seguridad de movimiento es global. theme.css ya incluye un guardián @media (prefers-reduced-motion: reduce) que neutraliza todas las animaciones y transiciones (spinners/skeletons del kit y cualquier otra que agregues). No lo repitas por canvas — pero mantén el movimiento decorativo, nunca la única señal.

API de vista — hooks, ayudantes, fragmentos

/kit/client.mjs reexporta el conjunto completo de hooks de Preact, no solo useState: useEffect, useRef, useMemo, useCallback, useReducer, useContext, useLayoutEffect, useImperativeHandle. Llama a los hooks incondicionalmente en la parte superior de un componente — antes de cualquier return temprano (p. ej., la rama de carga if (!state) return … viene después de tus hooks).

También exporta ayudantes de formato e id para que dejes de reinventarlos:

import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";
nid();                       // "lq3k9f2a" — id único corto
relativeTime(iso);           // "hace 5m" / "hace 3h" / "hace 2d" / una fecha
compactNumber(2_300_000);    // "2.3M"
percent(1.25);               // "+1.25%"

nid también se puede importar en el lado del servidor en canvas.mjs mediante import { nid } from "./canvas-kit/format.mjs" (el generador hace esto).

Los formateadores tienen en cuenta la configuración regional — relativeTime termina en un toLocaleDateString(), y compactNumber/percent pasan por Number.prototype.toLocaleString, por lo que su salida depende de la configuración regional del anfitrión ("1.5K" vs "1,5 K", 2025-01-31 vs 31/01/2025). Mantén los valores crudos en el estado duradero (una cadena ISO, un Number) y formatea en la vista. Nunca almacenes una cadena formateada y nunca la analices de vuelta — el formateo es una preocupación de presentación, y un valor formateado bajo una configuración regional no se puede releer de manera confiable bajo otra.

Fragment NO se exporta. Para devolver nodos hermanos sin un contenedor, usa la sintaxis de etiqueta vacía incorporada de htm — html`<><${A} /><${B} /></>` — o simplemente devuelve un array de vnodes. No uses import { Fragment }; no está ahí.

Canvases de datos externos — obtención + actualización automática

La mayoría de los canvases reales no son listas CRUD ingresadas por el usuario; obtienen datos de la red y se actualizan. La forma:

  1. fetch() vive en el manejador de acciones (o un ayudante que este llama), nunca en la vista. Siempre vincúlalo con un tiempo de espera para que un servicio ascendente lento no pueda colgar el panel:

    async function fetchItems(url) {
      const res = await fetch(url, { signal: AbortSignal.timeout(12000) });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return mapItems(await res.json());
    }
    

    La obtención se ejecuta en el lado del servidor en el runtime de bucle invertido, por lo que la plantilla data generada lo protege con una verificación SSRF: permite solo http/https a un anfitrión público y rechaza loopback, link-local (incl. metadatos de nube) y rangos privados — incluyendo cada dirección a la que se resuelve el nombre del anfitrión. Cualquier cosa que le devuelvas al agente (p. ej., a través de list_items) sigue siendo texto influenciado por un atacante, por lo que mantén la fuente que tú elijas y trata el contenido obtenido como no confiable (represéntalo como texto, nunca innerHTML).

  2. Expón una acción refresh que obtenga y escriba el resultado en el estado compartido. Captura los fallos en state.error y devuelve (no lances) para que un tic de consulta periódica en segundo plano permanezca silencioso mientras se muestra la tarjeta de error. Captura la entrada antes del await, y escribela de vuelta con un set funcional para que una acción concurrente (otra consulta, un set_source) no sea sobrescrita:

    refresh: {
      inputSchema: { type: "object", properties: {}, additionalProperties: false },
      handler: async ({ state, set }) => {
        const sourceUrl = state.sourceUrl;          // capturar antes de await
        try {
          const items = await fetchItems(sourceUrl);
          set((cur) => cur.sourceUrl !== sourceUrl  // descartar una respuesta desactualizada
            ? cur
            : { ...cur, items, error: null, lastRefresh: new Date().toISOString() });
          return { count: items.length };
        } catch (err) {
          set((cur) => ({ ...cur, error: `No se pudo cargar: ${err.message}`, lastRefresh: new Date().toISOString() }));
          return { ok: false, error: String(err.message) };
        }
      },
    },
    
  3. Consulta periódica con el kit, no con un setInterval crudo. pollWhileVisible(tick, seconds) ejecuta tick en un intervalo solo mientras el panel es visible (por lo que un canvas en segundo plano deja de golpear la red) y devuelve una función de limpieza — un useEffect de retorno directo:

    import { pollWhileVisible } from "/kit/client.mjs";
    // intervalo vinculado al estado duradero; se reconstruye cuando cambia:
    useEffect(() => pollWhileVisible(() => invoke("refresh"), state.autoRefreshSec || 0),
              [state.autoRefreshSec]);
    

    Para un intervalo fijo puedes omitir el efecto por completo: mountCanvas({ view, poll: { action: "refresh", seconds: 60, immediate: true } }).

Sella toda esta forma con node scripts/new-canvas.mjs <nombre> --template data.

Estrategia de dominio — tablero compartido vs perfil personal

El estado se almacena mediante la id de dominio que resolveDomainId devuelve. Dos intenciones, maquinaria idéntica (fileFor sanitiza la id a un nombre de archivo — cópialo textualmente):

  • Tablero compartido (la mayoría de los canvases): clave basada en un tema que tanto el agente como el usuario nombran. resolveDomainId: (input) => (input?.domain ? String(input.domain) : "default"). Cualquiera que abra domain: "tech" ve el mismo tablero.
  • Almacenamiento personal: clave basada en una identidad, no en un tema. resolveDomainId: (input) => (input?.profile ? String(input.profile) : "default"), con la propiedad del esquema de entrada llamada profile. Úsalo para datos por alumno/usuario que no deben mezclarse entre temas.

Elige la palabra (domain vs profile) deliberadamente — es todo el contrato de quién comparte qué.

Patrones y limitaciones (conócelos)

  • Catálogo empaquetado. Para datos de semilla seleccionados a mano (cursos, ajustes preestablecidos), envía un catalog.mjs separado: datos simples más un constructor de estilo buildCourse(...) que asigna ids posicionales deterministas (es-u1-l2) para que el mismo catálogo siempre produzca los mismos ids. Mantén los datos masivos concisos con pequeños ayudantes de constructor.
  • statusLine se calcula una vez, al abrir. El texto de estado del panel se establece en openInstance y no se vuelve a enviar cuando una acción muta el estado, por lo que un conteo en statusLine se vuelve obsoleto. Muestra los conteos en vivo dentro del cuerpo del canvas (que se vuelve a renderizar en cada inserción SSE) y trata statusLine como un resumen solo al abrir.
  • Acciones con carga rica. Los inputSchema de las acciones tienen por defecto additionalProperties: false a propósito: el esquema es el contrato que el agente completa, y un esquema estricto evita que el modelo introduzca campos no validados o con errores tipográficos — solo las propiedades que declaras llegan a tu manejador. Cuando la interfaz de usuario necesita genuinamente pasar al manejador un objeto completo (p. ej., el artículo que se guarda) en lugar de campos escalares, establece additionalProperties: true en el inputSchema de esa acción y desnormaliza lo que necesitas en el estado duradero en el manejador. Actívalo deliberadamente, por acción — no como un valor predeterminado general.
  • Diseño de instalación / resolución de SDK. Una carpeta de extensión enviada contiene solo copilot-extension.json ({ "name", "version": 1 } — sin package.json), extension.mjs, canvas.mjs, web/, y el canvas-kit/ anidado. El kit es ESM simple que Node ejecuta directamente — sin empaquetador, sin transpilación, sin paso de compilación, sin package.json en la extensión (el test/smoke.test.mjs estampado se ejecuta con node sin más). El anfitrión resuelve @github/copilot-sdk/extension por ti; extension.mjs es el único archivo que lo importa. No agregues un package.json o un paso de compilación a un canvas enviado.

Construir un canvas — la ruta más rápida

  1. Séllalo (un canvas funcional, con el kit ya anidado, incluye una prueba de humo):
    node scripts/new-ca
    

…

[truncado para traducción]

Skills relacionados