feat: scaffold vite chrome extension

This commit is contained in:
2026-07-17 14:08:03 -05:00
parent 3820f16afd
commit 8e4bb5861a
16 changed files with 2301 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
VITE_GITEA_BASE_URL=http://gitea.orson.tealthrone
VITE_GITEA_TOKEN=
VITE_GITEA_REPO_OWNER=jacob-mathison
VITE_GITEA_REPO_NAME=mp-silma-ai-aide
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.*
!.env.example
+38
View File
@@ -0,0 +1,38 @@
# AGENTS.md
## Project
`mp-silma-ai-aide` is a Vite + TypeScript Chrome extension scaffold. The built `dist/` directory is intended to be loaded in Chrome as an unpacked Manifest V3 extension.
## Commands
- Install dependencies: `npm install`
- Run local Vite dev server: `npm run dev`
- Build extension: `npm run build`
- Preview build output: `npm run preview`
## Environment
Configuration is read from Vite environment variables:
- `VITE_GITEA_BASE_URL`: Base URL for the Gitea instance, for example `http://gitea.orson.tealthrone`.
- `VITE_GITEA_TOKEN`: Gitea API token.
- `VITE_GITEA_REPO_OWNER`: Target Gitea owner. Default: `jacob-mathison`.
- `VITE_GITEA_REPO_NAME`: Target Gitea repository. Default: `mp-silma-ai-aide`.
Use `.env.local` for local secrets. Keep `.env.local` and any real token-bearing env files out of git. Update `.env.example` when adding new required variables.
## Extension Build Notes
- `vite.config.ts` emits `dist/manifest.json` during production builds.
- `dist/` is generated output and should not be edited directly.
- Host permissions are derived from `VITE_GITEA_BASE_URL`. Rebuild after changing the base URL.
- Runtime API calls are centralized in `src/api.ts`.
- Popup state is centralized in the vanilla Zustand store at `src/store.ts`.
## Coding Guidelines
- Keep the app TypeScript-first and strict.
- Prefer small, focused modules under `src/`.
- Do not commit secrets or generated build output.
- Run `npm run build` before handing off code changes.
+17 -1
View File
@@ -1,3 +1,19 @@
# mp-silma-ai-aide # mp-silma-ai-aide
Context-aware AI aide — project-agnostic tooling to give Silma better context for supporting any codebase or task. Context-aware AI aide — project-agnostic tooling to give Silma better context for supporting any codebase or task.
## Development
Install dependencies:
```bash
npm install
```
Build the Chrome extension:
```bash
npm run build
```
Load the generated `dist/` folder in Chrome as an unpacked extension.
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Silma AI Aide</title>
</head>
<body>
<main class="app-shell">
<header class="masthead">
<div>
<p class="eyebrow">Gitea</p>
<h1>Silma AI Aide</h1>
</div>
<span id="config-badge" class="badge">Checking</span>
</header>
<section class="panel">
<dl class="config-list">
<div>
<dt>Base URL</dt>
<dd id="base-url">Not configured</dd>
</div>
<div>
<dt>Token</dt>
<dd id="token-state">Not configured</dd>
</div>
<div>
<dt>Target repo</dt>
<dd id="target-repo">Not configured</dd>
</div>
</dl>
<button id="test-connection" type="button">Test connection</button>
<p id="status" role="status">Ready.</p>
</section>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
# mp-silma-ai-aide
This project is a basic Vite + TypeScript Chrome extension scaffold. The extension popup reads Gitea configuration from Vite env variables, stores popup state in Zustand, and uses Axios to call the Gitea API.
Important files:
- `AGENTS.md`: Project instructions for coding agents.
- `manifest.llm.json`: Machine-readable project metadata.
- `.env.example`: Required environment variables.
- `vite.config.ts`: Vite build config and generated Chrome extension manifest logic.
- `src/api.ts`: Axios client and Gitea configuration handling.
- `src/store.ts`: Vanilla Zustand popup state store.
- `src/main.ts`: Popup UI behavior.
- `index.html`: Popup document.
Build with `npm run build`. Load the generated `dist/` folder in Chrome as an unpacked extension. Do not edit `dist/` directly.
Required env variables:
- `VITE_GITEA_BASE_URL`
- `VITE_GITEA_TOKEN`
- `VITE_GITEA_REPO_OWNER`
- `VITE_GITEA_REPO_NAME`
+57
View File
@@ -0,0 +1,57 @@
{
"schema_version": "1.0",
"name": "mp-silma-ai-aide",
"description": "A Vite and TypeScript Chrome extension popup for Gitea-backed Silma AI aide workflows.",
"project_type": "chrome-extension",
"language": "typescript",
"package_manager": "npm",
"entrypoints": {
"popup_html": "index.html",
"popup_script": "src/main.ts",
"state_store": "src/store.ts",
"api_client": "src/api.ts",
"vite_config": "vite.config.ts",
"generated_manifest": "dist/manifest.json"
},
"commands": {
"install": "npm install",
"dev": "npm run dev",
"build": "npm run build",
"preview": "npm run preview"
},
"environment": {
"example_file": ".env.example",
"local_file": ".env.local",
"variables": [
{
"name": "VITE_GITEA_BASE_URL",
"required": true,
"secret": false,
"description": "Base URL for the Gitea instance."
},
{
"name": "VITE_GITEA_TOKEN",
"required": true,
"secret": true,
"description": "Gitea API token used by the popup client."
},
{
"name": "VITE_GITEA_REPO_OWNER",
"required": true,
"secret": false,
"description": "Owner for the target Gitea repository."
},
{
"name": "VITE_GITEA_REPO_NAME",
"required": true,
"secret": false,
"description": "Name of the target Gitea repository."
}
]
},
"generated_paths": [
"dist/",
"node_modules/"
],
"agent_guidance": "See AGENTS.md for coding and build instructions. Do not edit dist/ directly; rebuild from source instead."
}
+1710
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "mp-silma-ai-aide",
"version": "1.0.0",
"description": "Chrome extension popup built with Vite for Silma AI aide workflows.",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.18.1",
"dotenv": "^17.4.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/chrome": "^0.2.2",
"@types/node": "^26.1.1",
"typescript": "^7.0.2",
"vite": "^8.1.5"
}
}
+46
View File
@@ -0,0 +1,46 @@
import axios from "axios";
const rawGiteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL?.trim() ?? "";
const giteaToken = import.meta.env.VITE_GITEA_TOKEN?.trim() ?? "";
const rawGiteaRepoOwner = import.meta.env.VITE_GITEA_REPO_OWNER?.trim() ?? "";
const rawGiteaRepoName = import.meta.env.VITE_GITEA_REPO_NAME?.trim() ?? "";
export interface GiteaUser {
id: number;
login: string;
full_name?: string;
email?: string;
}
export const giteaConfig = {
baseUrl: rawGiteaBaseUrl,
hasToken: giteaToken.length > 0,
repoOwner: rawGiteaRepoOwner,
repoName: rawGiteaRepoName,
targetRepo:
rawGiteaRepoOwner.length > 0 && rawGiteaRepoName.length > 0
? `${rawGiteaRepoOwner}/${rawGiteaRepoName}`
: "",
isConfigured: rawGiteaBaseUrl.length > 0 && giteaToken.length > 0
};
function getApiBaseUrl(baseUrl: string): string {
const normalized = baseUrl.replace(/\/+$/, "");
return normalized.endsWith("/api/v1") ? normalized : `${normalized}/api/v1`;
}
export async function getCurrentGiteaUser(): Promise<GiteaUser> {
if (!giteaConfig.isConfigured) {
throw new Error("Set VITE_GITEA_BASE_URL and VITE_GITEA_TOKEN before testing the connection.");
}
const client = axios.create({
baseURL: getApiBaseUrl(giteaConfig.baseUrl),
headers: {
Authorization: `token ${giteaToken}`
}
});
const response = await client.get<GiteaUser>("/user");
return response.data;
}
+45
View File
@@ -0,0 +1,45 @@
import "./styles.css";
import { appStore } from "./store";
const configBadge = document.querySelector<HTMLSpanElement>("#config-badge");
const baseUrl = document.querySelector<HTMLElement>("#base-url");
const tokenState = document.querySelector<HTMLElement>("#token-state");
const targetRepo = document.querySelector<HTMLElement>("#target-repo");
const status = document.querySelector<HTMLElement>("#status");
const testConnection = document.querySelector<HTMLButtonElement>("#test-connection");
function setText(element: HTMLElement | null, text: string): void {
if (element) {
element.textContent = text;
}
}
function render(): void {
const state = appStore.getState();
const config = state.config;
setText(baseUrl, config.baseUrl || "Not configured");
setText(tokenState, config.hasToken ? "Configured" : "Not configured");
setText(targetRepo, config.targetRepo || "Not configured");
setText(configBadge, config.isConfigured ? "Configured" : "Missing env");
if (configBadge) {
configBadge.dataset.tone = config.isConfigured ? "success" : "warning";
}
if (status) {
status.textContent = state.statusMessage;
status.dataset.tone = state.statusTone;
}
if (testConnection) {
testConnection.disabled = state.isTestingConnection;
}
}
appStore.subscribe(render);
render();
testConnection?.addEventListener("click", () => {
void appStore.getState().testConnection();
});
+47
View File
@@ -0,0 +1,47 @@
import { createStore } from "zustand/vanilla";
import { getCurrentGiteaUser, giteaConfig, type GiteaUser } from "./api";
export type StatusTone = "neutral" | "success" | "error";
interface AppState {
config: typeof giteaConfig;
connectedUser: GiteaUser | null;
isTestingConnection: boolean;
statusMessage: string;
statusTone: StatusTone;
testConnection: () => Promise<void>;
}
export const appStore = createStore<AppState>()((set) => ({
config: giteaConfig,
connectedUser: null,
isTestingConnection: false,
statusMessage: "Ready.",
statusTone: "neutral",
testConnection: async () => {
set({
connectedUser: null,
isTestingConnection: true,
statusMessage: "Testing connection...",
statusTone: "neutral"
});
try {
const user = await getCurrentGiteaUser();
set({
connectedUser: user,
statusMessage: `Connected as ${user.full_name || user.login}.`,
statusTone: "success"
});
} catch (error) {
const message = error instanceof Error ? error.message : "Connection failed.";
set({
connectedUser: null,
statusMessage: message,
statusTone: "error"
});
} finally {
set({ isTestingConnection: false });
}
}
}));
+139
View File
@@ -0,0 +1,139 @@
:root {
color: #1f2933;
background: #f5f7fa;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
min-width: 360px;
margin: 0;
background: #f5f7fa;
}
.app-shell {
display: grid;
gap: 14px;
padding: 16px;
}
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.eyebrow {
margin: 0 0 4px;
color: #52616f;
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
margin: 0;
color: #102a43;
font-size: 22px;
line-height: 1.15;
}
.badge {
flex: 0 0 auto;
border: 1px solid #bcccdc;
border-radius: 999px;
padding: 5px 9px;
background: #ffffff;
color: #334e68;
font-size: 12px;
font-weight: 700;
}
.badge[data-tone="success"] {
border-color: #7bc47f;
background: #e3f9e5;
color: #1b5e20;
}
.badge[data-tone="warning"] {
border-color: #f7c948;
background: #fffbea;
color: #8d6e00;
}
.panel {
display: grid;
gap: 14px;
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 14px;
background: #ffffff;
box-shadow: 0 8px 24px rgb(16 42 67 / 8%);
}
.config-list {
display: grid;
gap: 10px;
margin: 0;
}
.config-list div {
display: grid;
gap: 3px;
}
dt {
color: #52616f;
font-size: 12px;
font-weight: 700;
}
dd {
margin: 0;
overflow-wrap: anywhere;
color: #102a43;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 13px;
}
button {
width: 100%;
border: 0;
border-radius: 6px;
padding: 10px 12px;
background: #0b6bcb;
color: #ffffff;
cursor: pointer;
font: inherit;
font-weight: 700;
}
button:disabled {
cursor: wait;
opacity: 0.7;
}
#status {
min-height: 20px;
margin: 0;
color: #52616f;
font-size: 13px;
line-height: 1.4;
}
#status[data-tone="success"] {
color: #1b5e20;
}
#status[data-tone="error"] {
color: #b42318;
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GITEA_BASE_URL?: string;
readonly VITE_GITEA_TOKEN?: string;
readonly VITE_GITEA_REPO_OWNER?: string;
readonly VITE_GITEA_REPO_NAME?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["chrome"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}
+72
View File
@@ -0,0 +1,72 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { parse as parseDotenv } from "dotenv";
import { defineConfig, type Plugin } from "vite";
function loadExtensionEnv(mode: string): NodeJS.ProcessEnv {
const fileEnv: Record<string, string> = {};
for (const fileName of [".env", ".env.local", `.env.${mode}`, `.env.${mode}.local`]) {
const path = resolve(process.cwd(), fileName);
if (existsSync(path)) {
Object.assign(fileEnv, parseDotenv(readFileSync(path)));
}
}
return { ...fileEnv, ...process.env };
}
function getGiteaHostPermissions(baseUrl?: string): string[] {
if (!baseUrl?.trim()) {
return [];
}
try {
const url = new URL(baseUrl);
return [`${url.protocol}//${url.host}/*`];
} catch {
console.warn(`VITE_GITEA_BASE_URL is not a valid URL: ${baseUrl}`);
return [];
}
}
function chromeExtensionManifest(mode: string): Plugin {
return {
name: "chrome-extension-manifest",
generateBundle() {
const env = loadExtensionEnv(mode);
const manifest = {
manifest_version: 3,
name: "Silma AI Aide",
version: "0.1.0",
description: "A Vite-powered popup for Gitea-backed Silma AI aide workflows.",
action: {
default_title: "Silma AI Aide",
default_popup: "index.html"
},
permissions: ["storage"],
host_permissions: getGiteaHostPermissions(env.VITE_GITEA_BASE_URL)
};
this.emitFile({
type: "asset",
fileName: "manifest.json",
source: `${JSON.stringify(manifest, null, 2)}\n`
});
}
};
}
export default defineConfig(({ mode }) => ({
plugins: [chromeExtensionManifest(mode)],
build: {
outDir: "dist",
emptyOutDir: true,
rollupOptions: {
input: {
popup: resolve(__dirname, "index.html")
}
}
}
}));