Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e012047b | ||
|
|
6b8431a09b | ||
|
|
114d3c92d8 | ||
|
|
966ec0fd60 | ||
|
|
047d3f1843 | ||
|
|
64d7782869 | ||
|
|
b6dc2c0a6d | ||
|
|
b0d8a57ea2 | ||
|
|
cdd576ed1f | ||
|
|
cd89e9bd60 | ||
|
|
daa6a6a38c | ||
|
|
05ebd0b307 | ||
|
|
86e1866d3b | ||
|
|
c6487166c9 | ||
|
|
8cf8d32ce1 | ||
|
|
664e602886 |
@@ -216,7 +216,6 @@ jobs:
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: Build plugin
|
||||
working-directory: packages/jetbrains
|
||||
@@ -229,12 +228,13 @@ jobs:
|
||||
name: jetbrains
|
||||
path: packages/jetbrains/build/distributions/*.zip
|
||||
|
||||
# ─── GitHub Release (only when all builds pass on tag) ───
|
||||
# ─── GitHub Release (on tag, after core build passes) ───
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, build-safari, build-jetbrains]
|
||||
if: >-
|
||||
always() &&
|
||||
github.ref_type == 'tag' &&
|
||||
needs.build.result == 'success'
|
||||
steps:
|
||||
@@ -254,17 +254,18 @@ jobs:
|
||||
echo "tag=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "name=Release $VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Collect release assets
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f \( -name '*.zip' -o -name '*.vsix' -o -name '*.dxt' \) -exec cp {} release/ \;
|
||||
echo "Release assets:" && ls -la release/
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.info.outputs.tag }}
|
||||
name: ${{ steps.info.outputs.name }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/extensions/browser/hanzo-ai-chrome-*.zip
|
||||
artifacts/extensions/browser/hanzo-ai-firefox-*.zip
|
||||
artifacts/extensions/vscode/*.vsix
|
||||
artifacts/safari/hanzo-ai-safari-*.zip
|
||||
artifacts/jetbrains/*.zip
|
||||
files: release/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: Build and publish
|
||||
working-directory: packages/jetbrains
|
||||
run: ./gradlew publishPlugin
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hanzo-ai-monorepo",
|
||||
"version": "1.6.3",
|
||||
"version": "1.7.14",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Development Platform Monorepo",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/dev.git"
|
||||
"url": "https://github.com/hanzoai/extension.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4",
|
||||
"engines": {
|
||||
@@ -30,4 +30,4 @@
|
||||
"package:vscode": "pnpm --filter @hanzo/extension run package",
|
||||
"package:dxt": "pnpm --filter @hanzo/dxt run package"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
/**
|
||||
* End-to-end tests for the login + chat flow.
|
||||
*
|
||||
* These tests exercise the real Casdoor OAuth2 flow using the z@hanzo.ai
|
||||
* test account. They verify:
|
||||
* 1. Login tab opens correctly
|
||||
* 2. Password auth succeeds
|
||||
* 3. Token is stored and user info is fetched
|
||||
* 4. Chat sends a message to a Zen model via llm.hanzo.ai
|
||||
*
|
||||
* Requirements:
|
||||
* - IAM at iam.hanzo.ai / hanzo.id must be reachable
|
||||
* - LLM gateway at llm.hanzo.ai must be reachable
|
||||
* - Extension must be built: npm run build
|
||||
*/
|
||||
|
||||
const TEST_USER = 'z';
|
||||
const TEST_EMAIL = 'z@hanzo.ai';
|
||||
|
||||
// These tests require live IAM/LLM access — skip in CI
|
||||
const descFn = process.env.CI ? test.describe.skip : test.describe;
|
||||
|
||||
descFn('Auth + Chat', () => {
|
||||
test('login via tab-based OAuth flow and verify user stored', async ({ context, extensionId }) => {
|
||||
const sidebar = await context.newPage();
|
||||
await sidebar.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
await sidebar.waitForTimeout(500);
|
||||
|
||||
// Switch to chat tab — should show login prompt
|
||||
await sidebar.locator('[data-tab="chat"]').click();
|
||||
const loginPrompt = sidebar.locator('#chat-login-prompt');
|
||||
await expect(loginPrompt).toBeVisible();
|
||||
|
||||
// Click "Sign in" — this sends auth.login to the background,
|
||||
// which opens a new tab to hanzo.id/login/oauth/authorize
|
||||
const [authPage] = await Promise.all([
|
||||
context.waitForEvent('page'),
|
||||
sidebar.locator('#auth-btn').click(),
|
||||
]);
|
||||
|
||||
// Wait for the Casdoor login page to load
|
||||
await authPage.waitForLoadState('networkidle');
|
||||
const authUrl = authPage.url();
|
||||
expect(authUrl).toContain('hanzo.id');
|
||||
|
||||
// Fill in credentials on the Casdoor login page
|
||||
// Casdoor uses a form with username + password inputs
|
||||
const usernameInput = authPage.locator('input[name="username"], input#input');
|
||||
const passwordInput = authPage.locator('input[name="password"], input[type="password"]');
|
||||
|
||||
if (await usernameInput.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await usernameInput.fill(TEST_USER);
|
||||
await passwordInput.fill('IloveHanzo2026!!!');
|
||||
|
||||
// Submit the form
|
||||
const submitBtn = authPage.locator('button[type="submit"], .login-button, button:has-text("Sign In"), button:has-text("Sign in")');
|
||||
await submitBtn.click();
|
||||
|
||||
// Wait for redirect — the background script should catch the callback URL
|
||||
// and close this tab automatically
|
||||
await authPage.waitForEvent('close', { timeout: 30_000 }).catch(() => {
|
||||
// Tab might not close if redirect is to a real page
|
||||
});
|
||||
} else {
|
||||
// If no form visible, Casdoor might have a different UI — skip
|
||||
test.skip(true, 'Casdoor login form not found');
|
||||
}
|
||||
|
||||
// Back on sidebar — verify auth state
|
||||
await sidebar.waitForTimeout(2000);
|
||||
|
||||
// Check storage for the access token
|
||||
const tokenStored = await sidebar.evaluate(() => {
|
||||
return new Promise(resolve => {
|
||||
chrome.storage.local.get(['hanzo_iam_access_token', 'hanzo_iam_user'], (result) => {
|
||||
resolve({
|
||||
hasToken: !!result.hanzo_iam_access_token,
|
||||
user: result.hanzo_iam_user,
|
||||
});
|
||||
});
|
||||
});
|
||||
}) as any;
|
||||
|
||||
expect(tokenStored.hasToken).toBe(true);
|
||||
expect(tokenStored.user).toBeTruthy();
|
||||
// /api/get-account should have returned name and email
|
||||
if (tokenStored.user) {
|
||||
expect(tokenStored.user.name).toBeTruthy();
|
||||
expect(tokenStored.user.email).toBe(TEST_EMAIL);
|
||||
}
|
||||
});
|
||||
|
||||
test('chat with Zen model after login', async ({ context, extensionId }) => {
|
||||
const sidebar = await context.newPage();
|
||||
await sidebar.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
|
||||
// Inject a mock token to skip the login flow
|
||||
await sidebar.evaluate(() => {
|
||||
return new Promise<void>(resolve => {
|
||||
// We need a real token for this test
|
||||
chrome.storage.local.set({
|
||||
hanzo_iam_access_token: 'test-will-be-replaced',
|
||||
hanzo_iam_user: { name: 'Test', email: 'z@hanzo.ai' },
|
||||
}, resolve);
|
||||
});
|
||||
});
|
||||
|
||||
// Get a real token via direct API call
|
||||
const tokenResult = await sidebar.evaluate(async () => {
|
||||
try {
|
||||
const resp = await fetch('https://iam.hanzo.ai/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
application: 'app-hanzo',
|
||||
organization: 'hanzo',
|
||||
username: 'z',
|
||||
password: 'IloveHanzo2026!!!',
|
||||
type: 'token',
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.status === 'ok' && data.data) {
|
||||
// Store the real token
|
||||
await new Promise<void>(resolve => {
|
||||
chrome.storage.local.set({ hanzo_iam_access_token: data.data }, resolve);
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: data.msg };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
expect(tokenResult.success).toBe(true);
|
||||
|
||||
// Reload to pick up the token
|
||||
await sidebar.reload();
|
||||
await sidebar.waitForTimeout(1000);
|
||||
|
||||
// Switch to chat tab
|
||||
await sidebar.locator('[data-tab="chat"]').click();
|
||||
|
||||
// Login prompt should be hidden, composer should be visible
|
||||
const composer = sidebar.locator('#chat-composer');
|
||||
await expect(composer).not.toHaveClass(/hidden/, { timeout: 5000 });
|
||||
|
||||
// Select a Zen model
|
||||
const modelSelect = sidebar.locator('#model-select');
|
||||
const options = await modelSelect.locator('option').allTextContents();
|
||||
const zenOption = options.find(o => o.toLowerCase().includes('zen'));
|
||||
if (zenOption) {
|
||||
await modelSelect.selectOption({ label: zenOption });
|
||||
}
|
||||
|
||||
// Type a message and send
|
||||
const chatInput = sidebar.locator('#chat-input');
|
||||
await chatInput.fill('Hello, say "pong" and nothing else.');
|
||||
await sidebar.locator('#send-btn').click();
|
||||
|
||||
// Wait for a response (the assistant message should appear)
|
||||
const assistantMsg = sidebar.locator('.message.assistant');
|
||||
await expect(assistantMsg.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Verify response contains text
|
||||
const responseText = await assistantMsg.first().textContent();
|
||||
expect(responseText).toBeTruthy();
|
||||
expect(responseText!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,10 @@ test.describe('Sidebar', () => {
|
||||
await expect(authBtn).toBeVisible();
|
||||
await expect(authBtn).toContainText('Sign in');
|
||||
|
||||
const signupBtn = page.locator('#signup-btn');
|
||||
await expect(signupBtn).toBeVisible();
|
||||
await expect(signupBtn).toContainText('Create account');
|
||||
|
||||
// Should have link to docs
|
||||
const docsLink = loginPrompt.locator('a[href="https://docs.hanzo.ai"]');
|
||||
await expect(docsLink).toBeAttached();
|
||||
@@ -127,7 +131,8 @@ test.describe('Sidebar', () => {
|
||||
await expect(page.locator('#mcp-tool-list')).toBeAttached();
|
||||
await expect(page.locator('#agent-count')).toBeAttached();
|
||||
await expect(page.locator('#tab-fs')).toBeAttached();
|
||||
await expect(page.locator('#gpu-status')).toBeAttached();
|
||||
await expect(page.locator('#hanzo-node-status')).toBeAttached();
|
||||
await expect(page.locator('#hanzo-mcp-status')).toBeAttached();
|
||||
});
|
||||
|
||||
test('settings tab has account and connection config', async ({ context, extensionId }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.14",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
@@ -26,6 +26,8 @@
|
||||
"@playwright/test": "^1.52.0",
|
||||
"esbuild": "^0.25.6",
|
||||
"jsdom": "^26.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^0.34.6",
|
||||
"ws": "^8.18.3"
|
||||
|
||||
@@ -49,7 +49,9 @@ function generateState(): string {
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IAM_BASE = 'https://hanzo.id';
|
||||
// Login UI lives on hanzo.id; Casdoor API is on iam.hanzo.ai
|
||||
const IAM_LOGIN = 'https://hanzo.id';
|
||||
const IAM_API = 'https://iam.hanzo.ai';
|
||||
const CLIENT_ID = 'app-hanzo';
|
||||
const SCOPES = 'openid profile email';
|
||||
|
||||
@@ -134,7 +136,8 @@ export async function login(): Promise<UserInfo> {
|
||||
const state = generateState();
|
||||
const redirectUri = getRedirectUri();
|
||||
|
||||
const authorizeUrl = new URL(`${IAM_BASE}/login/oauth/authorize`);
|
||||
// Authorization Code + PKCE (OAuth 2.1 standard for public clients)
|
||||
const authorizeUrl = new URL(`${IAM_LOGIN}/login/oauth/authorize`);
|
||||
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
@@ -150,40 +153,58 @@ export async function login(): Promise<UserInfo> {
|
||||
const returnedState = url.searchParams.get('state');
|
||||
if (returnedState !== state) throw new Error('State mismatch — possible CSRF');
|
||||
|
||||
// Handle both code flow and implicit token flow responses.
|
||||
// The login page may return tokens directly (type=token) or an auth code (type=code).
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
const error = url.searchParams.get('error_description') || url.searchParams.get('error') || 'No authorization code';
|
||||
const directToken = url.searchParams.get('access_token');
|
||||
|
||||
if (directToken) {
|
||||
// Implicit flow — tokens returned directly in redirect URL
|
||||
const tokens: TokenData = {
|
||||
access_token: directToken,
|
||||
refresh_token: url.searchParams.get('refresh_token') || undefined,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
await storeTokens(tokens);
|
||||
} else if (code) {
|
||||
// Authorization code flow — exchange code for tokens via PKCE
|
||||
const tokenResponse = await fetch(`${IAM_API}/api/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errText = await tokenResponse.text();
|
||||
throw new Error(`Token exchange failed: ${errText}`);
|
||||
}
|
||||
|
||||
const tokens: TokenData = await tokenResponse.json();
|
||||
await storeTokens(tokens);
|
||||
} else {
|
||||
const error = url.searchParams.get('error_description') || url.searchParams.get('error') || 'No authorization code or token';
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenResponse = await fetch(`${IAM_BASE}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errText = await tokenResponse.text();
|
||||
throw new Error(`Token exchange failed: ${errText}`);
|
||||
}
|
||||
|
||||
const tokens: TokenData = await tokenResponse.json();
|
||||
await storeTokens(tokens);
|
||||
|
||||
// Fetch user info
|
||||
const user = await fetchUserInfo(tokens.access_token);
|
||||
// Fetch user info using the stored token
|
||||
const { accessToken } = await getStoredTokens();
|
||||
if (!accessToken) throw new Error('Login succeeded but no token was stored');
|
||||
const user = await fetchUserInfo(accessToken);
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.user]: user });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function signup(): Promise<void> {
|
||||
await openExternalTab(`${IAM_LOGIN}/signup`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a browser tab for OAuth login and wait for the redirect.
|
||||
* Returns the full callback URL with authorization code.
|
||||
@@ -237,6 +258,18 @@ function openAuthTab(authorizeUrl: string, redirectUriPrefix: string): Promise<s
|
||||
});
|
||||
}
|
||||
|
||||
function openExternalTab(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.create({ url }, (tab) => {
|
||||
if (chrome.runtime.lastError || !tab?.id) {
|
||||
reject(new Error(chrome.runtime.lastError?.message || 'Failed to open tab'));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out — clear all stored tokens.
|
||||
*/
|
||||
@@ -276,10 +309,10 @@ export async function getValidAccessToken(): Promise<string | null> {
|
||||
* Refresh access token using refresh_token grant.
|
||||
*/
|
||||
async function refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
const response = await fetch(`${IAM_BASE}/oauth/token`, {
|
||||
const response = await fetch(`${IAM_API}/api/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
@@ -295,11 +328,30 @@ async function refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
|
||||
/**
|
||||
* Fetch user info from IAM.
|
||||
* /api/userinfo only returns sub/iss/aud; /api/get-account has full profile.
|
||||
*/
|
||||
async function fetchUserInfo(accessToken: string): Promise<UserInfo> {
|
||||
const response = await fetch(`${IAM_BASE}/oauth/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
// Try /api/get-account first (returns name, email, avatar, etc.)
|
||||
try {
|
||||
const acctResp = await fetch(`${IAM_API}/api/get-account`, { headers });
|
||||
if (acctResp.ok) {
|
||||
const acctJson = await acctResp.json();
|
||||
const acct = acctJson.data || acctJson;
|
||||
if (acct.name || acct.email) {
|
||||
return {
|
||||
sub: acct.id || acct.sub,
|
||||
name: acct.displayName || acct.name,
|
||||
email: acct.email,
|
||||
picture: acct.avatar || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Fallback to standard /api/userinfo
|
||||
const response = await fetch(`${IAM_API}/api/userinfo`, { headers });
|
||||
if (!response.ok) throw new Error('Failed to fetch user info');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -393,7 +393,9 @@ class HanzoFirefoxExtension {
|
||||
// Uses browser.identity.launchWebAuthFlow for OAuth
|
||||
// =============================================================================
|
||||
|
||||
const IAM_BASE = 'https://hanzo.id';
|
||||
// Login UI lives on hanzo.id; Casdoor API is on iam.hanzo.ai
|
||||
const IAM_LOGIN = 'https://hanzo.id';
|
||||
const IAM_API = 'https://iam.hanzo.ai';
|
||||
const API_BASE = 'https://api.hanzo.ai';
|
||||
const CLIENT_ID = 'app-hanzo';
|
||||
const SCOPES = 'openid profile email';
|
||||
@@ -525,7 +527,7 @@ async function firefoxLogin(): Promise<any> {
|
||||
const state = generateRandomString(32);
|
||||
const redirectUri = 'https://hanzo.ai/callback';
|
||||
|
||||
const authorizeUrl = new URL(`${IAM_BASE}/login/oauth/authorize`);
|
||||
const authorizeUrl = new URL(`${IAM_LOGIN}/login/oauth/authorize`);
|
||||
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
@@ -567,29 +569,65 @@ async function firefoxLogin(): Promise<any> {
|
||||
|
||||
const url = new URL(callbackUrl);
|
||||
if (url.searchParams.get('state') !== state) throw new Error('State mismatch');
|
||||
|
||||
// Handle both code flow and implicit token flow responses
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) throw new Error(url.searchParams.get('error_description') || 'No code');
|
||||
const directToken = url.searchParams.get('access_token');
|
||||
|
||||
const tokenResponse = await fetch(`${IAM_BASE}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code', client_id: CLIENT_ID,
|
||||
code, redirect_uri: redirectUri, code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
if (directToken) {
|
||||
// Implicit flow — tokens returned directly in redirect URL
|
||||
const data: any = { [STORAGE_KEYS.accessToken]: directToken };
|
||||
const refreshToken = url.searchParams.get('refresh_token');
|
||||
if (refreshToken) data[STORAGE_KEYS.refreshToken] = refreshToken;
|
||||
await browser.storage.local.set(data);
|
||||
} else if (code) {
|
||||
// Authorization code flow — exchange code for tokens via PKCE
|
||||
const tokenResponse = await fetch(`${IAM_API}/api/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
|
||||
const tokens = await tokenResponse.json();
|
||||
if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
const data: any = { [STORAGE_KEYS.accessToken]: tokens.access_token };
|
||||
if (tokens.refresh_token) data[STORAGE_KEYS.refreshToken] = tokens.refresh_token;
|
||||
if (tokens.id_token) data[STORAGE_KEYS.idToken] = tokens.id_token;
|
||||
if (tokens.expires_in) data[STORAGE_KEYS.expiresAt] = Date.now() + tokens.expires_in * 1000;
|
||||
await browser.storage.local.set(data);
|
||||
const data: any = { [STORAGE_KEYS.accessToken]: tokens.access_token };
|
||||
if (tokens.refresh_token) data[STORAGE_KEYS.refreshToken] = tokens.refresh_token;
|
||||
if (tokens.id_token) data[STORAGE_KEYS.idToken] = tokens.id_token;
|
||||
if (tokens.expires_in) data[STORAGE_KEYS.expiresAt] = Date.now() + tokens.expires_in * 1000;
|
||||
await browser.storage.local.set(data);
|
||||
} else {
|
||||
const error = url.searchParams.get('error_description') || url.searchParams.get('error') || 'No authorization code or token';
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const userResp = await fetch(`${IAM_BASE}/oauth/userinfo`, { headers: { Authorization: `Bearer ${tokens.access_token}` } });
|
||||
const user = userResp.ok ? await userResp.json() : {};
|
||||
// Fetch user info
|
||||
const stored = await browser.storage.local.get([STORAGE_KEYS.accessToken]);
|
||||
const accessToken = stored[STORAGE_KEYS.accessToken];
|
||||
if (!accessToken) throw new Error('Login succeeded but no token was stored');
|
||||
|
||||
// /api/userinfo only returns sub/iss/aud; /api/get-account has full profile
|
||||
let user: any = {};
|
||||
try {
|
||||
const acctResp = await fetch(`${IAM_API}/api/get-account`, { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||
if (acctResp.ok) {
|
||||
const acctJson = await acctResp.json();
|
||||
const acct = acctJson.data || acctJson;
|
||||
if (acct.name || acct.email) {
|
||||
user = { sub: acct.id || acct.sub, name: acct.displayName || acct.name, email: acct.email, picture: acct.avatar || undefined };
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
if (!user.name && !user.email) {
|
||||
const userResp = await fetch(`${IAM_API}/api/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||
if (userResp.ok) user = await userResp.json();
|
||||
}
|
||||
await browser.storage.local.set({ [STORAGE_KEYS.user]: user });
|
||||
return user;
|
||||
}
|
||||
@@ -606,6 +644,15 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth.signup':
|
||||
try {
|
||||
await browser.tabs.create({ url: `${IAM_LOGIN}/signup` });
|
||||
sendResponse({ success: true });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth.logout':
|
||||
await browser.storage.local.remove(Object.values(STORAGE_KEYS));
|
||||
sendResponse({ success: true });
|
||||
|
||||
@@ -934,6 +934,15 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth.signup':
|
||||
try {
|
||||
await auth.signup();
|
||||
sendResponse({ success: true });
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth.logout':
|
||||
try {
|
||||
await auth.logout();
|
||||
@@ -1163,6 +1172,17 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
break;
|
||||
}
|
||||
|
||||
case 'settings.sync': {
|
||||
// Sync all settings to ~/.hanzo/extension/config.json via CDP bridge
|
||||
if (request.settings && typeof request.settings === 'object') {
|
||||
for (const [key, value] of Object.entries(request.settings)) {
|
||||
cdpBridge.sendConfig(key, value);
|
||||
}
|
||||
}
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Takeover messages (forwarded from CDP bridge to content script) ---
|
||||
case 'hanzo.takeover.start': {
|
||||
const takeoverTabId = await resolveControlTabId(request.tabId);
|
||||
|
||||
@@ -12,22 +12,25 @@ async function build() {
|
||||
const hanzoUiPrimitives = path.join(hanzoUiRoot, 'pkg/ui/dist/primitives/index-common.js');
|
||||
const localReact = path.join(hanzoUiRoot, 'node_modules/react');
|
||||
const localReactDom = path.join(hanzoUiRoot, 'node_modules/react-dom');
|
||||
const forceLocalUiShim = process.env.HANZO_FORCE_LOCAL_UI_SHIM === '1';
|
||||
const canUseSharedUi =
|
||||
!forceLocalUiShim &&
|
||||
fs.existsSync(hanzoUiPrimitives) &&
|
||||
fs.existsSync(localReact) &&
|
||||
fs.existsSync(localReactDom);
|
||||
|
||||
if (!canUseSharedUi) {
|
||||
throw new Error(
|
||||
if (canUseSharedUi) {
|
||||
console.log('Using shared @hanzo/ui primitives from sibling repo:', hanzoUiPrimitives);
|
||||
} else {
|
||||
console.warn(
|
||||
[
|
||||
'Shared @hanzo/ui dependencies were not found.',
|
||||
'Shared @hanzo/ui dependencies were not found. Falling back to local primitives shim.',
|
||||
`Expected: ${hanzoUiPrimitives}`,
|
||||
`Expected: ${localReact}`,
|
||||
`Expected: ${localReactDom}`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
console.log('Using shared @hanzo/ui primitives from sibling repo:', hanzoUiPrimitives);
|
||||
|
||||
// Ensure dist directories exist
|
||||
fs.mkdirSync('dist/browser-extension', { recursive: true });
|
||||
@@ -78,11 +81,15 @@ async function build() {
|
||||
});
|
||||
|
||||
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback)
|
||||
const sidebarAliases = {
|
||||
'@hanzo/ui/primitives-common': hanzoUiPrimitives,
|
||||
react: localReact,
|
||||
'react-dom': localReactDom,
|
||||
};
|
||||
const sidebarAliases = canUseSharedUi
|
||||
? {
|
||||
'@hanzo/ui/primitives-common': hanzoUiPrimitives,
|
||||
react: localReact,
|
||||
'react-dom': localReactDom,
|
||||
}
|
||||
: {
|
||||
'@hanzo/ui/primitives-common': path.join(__dirname, 'primitives-common-fallback.tsx'),
|
||||
};
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [fs.existsSync('src/sidebar.ts') ? 'src/sidebar.ts' : 'src/sidebar.js'],
|
||||
@@ -314,4 +321,7 @@ server.on('elementSelected', (data) => {
|
||||
console.log('\nTo publish to npm: cd dist/browser-extension && npm publish');
|
||||
}
|
||||
|
||||
build().catch(console.error);
|
||||
build().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@ function LoginPrompt() {
|
||||
<Button id="auth-btn" className="chat-modern-auth-btn" type="button">
|
||||
Sign in
|
||||
</Button>
|
||||
<Button id="signup-btn" className="secondary-btn" type="button">
|
||||
Create account
|
||||
</Button>
|
||||
<p className="auth-note">
|
||||
Browser tools work without sign-in.{' '}
|
||||
<a href="https://docs.hanzo.ai" target="_blank" rel="noreferrer">
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 956 B |
|
Before Width: | Height: | Size: 345 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 576 B After Width: | Height: | Size: 1.6 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.14",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
@@ -21,13 +21,19 @@
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.js"],
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content-script.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"scripts": [
|
||||
"background.js"
|
||||
],
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
@@ -48,8 +54,13 @@
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["ai-worker.js", "models/*"],
|
||||
"matches": ["<all_urls>"]
|
||||
"resources": [
|
||||
"ai-worker.js",
|
||||
"models/*"
|
||||
],
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
]
|
||||
}
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
@@ -60,7 +71,11 @@
|
||||
"gecko_android": {}
|
||||
},
|
||||
"data_collection_permissions": {
|
||||
"purposes": ["functionality"],
|
||||
"data_categories": ["technical_data"]
|
||||
"purposes": [
|
||||
"functionality"
|
||||
],
|
||||
"data_categories": [
|
||||
"technical_data"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.14",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
@@ -23,8 +23,12 @@
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.js"],
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content-script.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
@@ -50,8 +54,13 @@
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["ai-worker.js", "models/*"],
|
||||
"matches": ["<all_urls>"]
|
||||
"resources": [
|
||||
"ai-worker.js",
|
||||
"models/*"
|
||||
],
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
type Classy = { className?: string; children?: React.ReactNode };
|
||||
|
||||
function cx(base: string, extra?: string): string {
|
||||
return extra ? `${base} ${extra}` : base;
|
||||
}
|
||||
|
||||
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<button {...rest} className={cx('hanzo-ui-btn', className)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<textarea {...rest} className={cx('hanzo-ui-textarea', className)}>
|
||||
{children}
|
||||
</textarea>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card-header', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, children }: Classy) {
|
||||
return <h3 className={cx('hanzo-ui-card-title', className)}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, children }: Classy) {
|
||||
return <p className={cx('hanzo-ui-card-description', className)}>{children}</p>;
|
||||
}
|
||||
|
||||
export function CardContent({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card-content', className)}>{children}</div>;
|
||||
}
|
||||
@@ -39,11 +39,18 @@ body {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ==================== Shimmer Keyframes ==================== */
|
||||
@@ -86,13 +93,15 @@ body {
|
||||
/* ==================== Layout ==================== */
|
||||
|
||||
.sidebar-container {
|
||||
width: 320px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ambient glow behind sidebar */
|
||||
@@ -274,6 +283,11 @@ body {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-modern-login-card #signup-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-modern-login-card .auth-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
@@ -348,6 +362,7 @@ body {
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -357,22 +372,30 @@ body {
|
||||
.tools-scroll,
|
||||
.settings-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.08) transparent;
|
||||
}
|
||||
|
||||
/* ==================== Chat ==================== */
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.08) transparent;
|
||||
}
|
||||
|
||||
.chat-welcome {
|
||||
@@ -467,138 +490,147 @@ body {
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
/* Chat Input */
|
||||
/* ==================== Composer (Grok-style) ==================== */
|
||||
|
||||
.chat-input-area {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
.composer {
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.chat-modern-composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-selector {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.model-selector select {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: var(--glass-strong);
|
||||
.composer-box {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: border-color 0.2s;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.model-selector select:focus {
|
||||
.composer-box:focus-within {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,0.06), 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.composer-box textarea {
|
||||
width: 100%;
|
||||
padding: 14px 16px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
outline: none;
|
||||
min-height: 44px;
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
.chat-flags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.flag-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.flag-toggle input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.rag-status {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
.composer-box textarea::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
.composer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px 8px 12px;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.input-row textarea {
|
||||
.composer-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.composer-model {
|
||||
padding: 3px 22px 3px 8px;
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
resize: none;
|
||||
outline: none;
|
||||
max-height: 120px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10' fill='none'%3E%3Cpath d='M2.5 4l2.5 2.5 2.5-2.5' stroke='%236B6B6B' stroke-width='1.2'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 6px center;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-row textarea:focus {
|
||||
.composer-model:focus {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,0.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-modern-input {
|
||||
min-height: 40px;
|
||||
.composer-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
.composer-toggle input[type="checkbox"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
accent-color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.composer-toggle:has(input:checked) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.composer-status {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.composer-send {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #000000;
|
||||
border-radius: 50%;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
.composer-send:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 0 12px rgba(255,255,255,0.15);
|
||||
box-shadow: 0 0 12px rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.3;
|
||||
.composer-send:disabled {
|
||||
opacity: 0.2;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.send-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.composer-send svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* ==================== Buttons ==================== */
|
||||
@@ -676,21 +708,19 @@ body {
|
||||
.secondary-btn {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
background: var(--glass-strong);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: var(--glass);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 8px rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -700,22 +730,20 @@ body {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: var(--glass-strong);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--glass);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 12px rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
@@ -726,15 +754,13 @@ body {
|
||||
/* ==================== Glass Panels ==================== */
|
||||
|
||||
.panel {
|
||||
background: var(--glass-strong);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(150%);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
transition: border-color 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Subtle top highlight on glass panels */
|
||||
@@ -830,25 +856,102 @@ body {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tool-list-wrap {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
.service-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.tool-list-header {
|
||||
color: var(--text-secondary);
|
||||
.action-btn.small {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-btn.small svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.tool-list {
|
||||
max-height: 90px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-tertiary);
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.08) transparent;
|
||||
}
|
||||
|
||||
.tool-category {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tool-category:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tool-category-name {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.tool-category-tools {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* MCP Server List */
|
||||
.mcp-server-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mcp-server-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.mcp-server-item:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.mcp-server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mcp-server-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mcp-server-tools {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ==================== User Profile ==================== */
|
||||
@@ -922,10 +1025,12 @@ body {
|
||||
/* ==================== Tab Filesystem ==================== */
|
||||
|
||||
.tab-filesystem {
|
||||
max-height: 200px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.08) transparent;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
@@ -951,12 +1056,15 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.setting-item span {
|
||||
.setting-item > span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.setting-item input[type="text"],
|
||||
@@ -964,14 +1072,26 @@ body {
|
||||
.setting-item select {
|
||||
width: 140px;
|
||||
padding: 6px 10px;
|
||||
background: var(--glass-strong);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.setting-item select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 5l3 3 3-3' stroke='%23A0A0A0' stroke-width='1.5'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-item input:focus,
|
||||
.setting-item select:focus {
|
||||
border-color: var(--border-strong);
|
||||
@@ -992,14 +1112,21 @@ body {
|
||||
.setting-item input[type="number"] {
|
||||
width: 70px;
|
||||
padding: 6px 10px;
|
||||
background: var(--glass-strong);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
-moz-appearance: textfield;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.setting-item input[type="number"]::-webkit-inner-spin-button,
|
||||
.setting-item input[type="number"]::-webkit-outer-spin-button {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.setting-item input[type="number"]:focus {
|
||||
border-color: var(--border-strong);
|
||||
outline: none;
|
||||
@@ -1008,10 +1135,22 @@ body {
|
||||
.setting-item input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Firefox: style option elements in dropdowns */
|
||||
select option {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* Color scheme hint for native widgets */
|
||||
:root { color-scheme: dark; }
|
||||
|
||||
.settings-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -1073,6 +1212,13 @@ body {
|
||||
|
||||
/* ==================== Scrollbar ==================== */
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.08) transparent;
|
||||
}
|
||||
|
||||
/* Chrome / Safari */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
|
||||
@@ -1082,6 +1228,43 @@ body {
|
||||
|
||||
.loading { animation: pulse 1.5s infinite; }
|
||||
|
||||
/* ==================== Footer ==================== */
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(0,0,0,0.4);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-brand:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.footer-brand svg {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.footer-brand:hover svg {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ==================== Agent Launcher Modal ==================== */
|
||||
|
||||
.modal-overlay {
|
||||
@@ -1147,7 +1330,7 @@ body {
|
||||
|
||||
.modal-body textarea {
|
||||
width: 100%;
|
||||
background: var(--glass-strong);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
@@ -1155,6 +1338,11 @@ body {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-body textarea:focus {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
|
||||
@@ -8,38 +8,14 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar-container">
|
||||
<!-- Header -->
|
||||
<header class="sidebar-header">
|
||||
<div class="brand">
|
||||
<svg class="logo" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span>Hanzo AI</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div id="user-badge" class="user-badge hidden" title="Account">
|
||||
<img id="header-avatar" class="header-avatar" src="" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Auth section removed — login prompt is now inside Chat tab -->
|
||||
<!-- Auth section (hidden — login prompt is inside Chat tab) -->
|
||||
<section id="auth-section" class="hidden"></section>
|
||||
|
||||
<!-- Tab Bar (always visible) -->
|
||||
<nav id="tab-bar" class="tab-bar hidden">
|
||||
<button class="tab active" data-tab="chat">Chat</button>
|
||||
<button class="tab" data-tab="tools">Tools</button>
|
||||
<button class="tab" data-tab="settings">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" width="14" height="14">
|
||||
<path d="M8 10a2 2 0 100-4 2 2 0 000 4z" stroke-width="1.5"/>
|
||||
<path d="M13 8a5 5 0 01-.4 2l1 1.7a7 7 0 01-1.3.8l-1-1.7a5 5 0 01-2 .4v2a7 7 0 01-1.5 0v-2a5 5 0 01-2-.4l-1 1.7a7 7 0 01-1.3-.8l1-1.7A5 5 0 013 8a5 5 0 01.4-2l-1-1.7a7 7 0 011.3-.8l1 1.7a5 5 0 012-.4V2.8a7 7 0 011.5 0v2a5 5 0 012 .4l1-1.7a7 7 0 011.3.8l-1 1.7A5 5 0 0113 8z" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="tab" data-tab="settings">Settings</button>
|
||||
</nav>
|
||||
|
||||
<!-- Chat Tab -->
|
||||
@@ -50,6 +26,7 @@
|
||||
<p class="auth-headline">Chat with Zen AI models</p>
|
||||
<p class="auth-sub">Claude, GPT-4o, Zen Coder Flash & more</p>
|
||||
<button id="auth-btn" class="primary-btn">Sign in</button>
|
||||
<button id="signup-btn" class="secondary-btn">Create account</button>
|
||||
<p class="auth-note">Browser tools work without sign-in. <a href="https://docs.hanzo.ai" target="_blank">Learn more</a></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,35 +35,35 @@
|
||||
<p>Ask anything. Powered by Hanzo Cloud.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chat-composer" class="chat-input-area">
|
||||
<div class="model-selector">
|
||||
<select id="model-select">
|
||||
<option value="auto">Auto (cost-aware)</option>
|
||||
<option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
|
||||
<option value="claude-opus-4-20250514">Claude Opus 4</option>
|
||||
<option value="gpt-4o">GPT-4o</option>
|
||||
<option value="zen-coder-flash">Zen Coder Flash</option>
|
||||
<option value="zen-max">Zen Max</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="chat-flags">
|
||||
<label class="flag-toggle">
|
||||
<input type="checkbox" id="rag-enabled" checked>
|
||||
<span>RAG</span>
|
||||
</label>
|
||||
<label class="flag-toggle">
|
||||
<input type="checkbox" id="tab-context-enabled" checked>
|
||||
<span>Tab Context</span>
|
||||
</label>
|
||||
<span id="rag-status" class="rag-status">Ready</span>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<div id="chat-composer" class="composer">
|
||||
<div class="composer-box">
|
||||
<textarea id="chat-input" placeholder="Ask anything..." rows="1"></textarea>
|
||||
<button id="send-btn" class="send-btn" title="Send">
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor">
|
||||
<path d="M4 10l12-6-6 12-2-6-4-2z" stroke-width="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="composer-bar">
|
||||
<div class="composer-controls">
|
||||
<select id="model-select" class="composer-model">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
|
||||
<option value="claude-opus-4-20250514">Claude Opus 4</option>
|
||||
<option value="gpt-4o">GPT-4o</option>
|
||||
<option value="zen-coder-flash">Zen Coder Flash</option>
|
||||
<option value="zen-max">Zen Max</option>
|
||||
</select>
|
||||
<label class="composer-toggle" title="RAG context">
|
||||
<input type="checkbox" id="rag-enabled" checked>
|
||||
<span>RAG</span>
|
||||
</label>
|
||||
<label class="composer-toggle" title="Include active tab">
|
||||
<input type="checkbox" id="tab-context-enabled" checked>
|
||||
<span>Tab</span>
|
||||
</label>
|
||||
<span id="rag-status" class="composer-status">Ready</span>
|
||||
</div>
|
||||
<button id="send-btn" class="composer-send" title="Send (Enter)">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path d="M3 13V3l10 5-10 5z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,14 +71,28 @@
|
||||
<!-- Tools Tab -->
|
||||
<div id="tab-tools" class="tab-content hidden">
|
||||
<div class="tools-scroll">
|
||||
<!-- Connection Status -->
|
||||
<!-- Hanzo Services -->
|
||||
<div class="panel">
|
||||
<h3>Connections</h3>
|
||||
<h3>Hanzo Services</h3>
|
||||
<div class="mcp-status">
|
||||
<div class="status-row">
|
||||
<span>Hanzo Node</span>
|
||||
<span id="hanzo-node-status" class="status-value">
|
||||
<span class="status-indicator disconnected"></span>
|
||||
Checking...
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>Hanzo Bot</span>
|
||||
<span id="hanzo-mcp-status" class="status-value">
|
||||
<span class="status-indicator disconnected"></span>
|
||||
Checking...
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>ZAP Protocol</span>
|
||||
<span id="mcp-status" class="status-value">
|
||||
<span class="status-indicator connected"></span>
|
||||
<span class="status-indicator disconnected"></span>
|
||||
Discovering...
|
||||
</span>
|
||||
</div>
|
||||
@@ -109,13 +100,45 @@
|
||||
<span>Tools Available</span>
|
||||
<span id="mcp-tools" class="status-value">0</span>
|
||||
</div>
|
||||
<div class="tool-list-wrap">
|
||||
<div class="tool-list-header">Discovered Tools</div>
|
||||
<div id="mcp-tool-list" class="tool-list">No tools discovered yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<button class="action-btn small" id="start-hanzo-node" title="Start hanzo node">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
||||
<path d="M4 2l10 6-10 6V2z"/>
|
||||
</svg>
|
||||
Start Node
|
||||
</button>
|
||||
<button class="action-btn small" id="start-hanzo-mcp" title="Start hanzo bot">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
||||
<path d="M4 2l10 6-10 6V2z"/>
|
||||
</svg>
|
||||
Start Bot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected MCP Servers -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>MCP Servers</h3>
|
||||
<span id="mcp-server-count" class="badge">0</span>
|
||||
</div>
|
||||
<div id="mcp-server-list" class="mcp-server-list">
|
||||
<div class="empty-state">No MCP servers connected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Built-in + Discovered Tools -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Available Tools</h3>
|
||||
<span id="tool-count-badge" class="badge">0</span>
|
||||
</div>
|
||||
<div id="builtin-tool-list" class="tool-list"></div>
|
||||
<div id="mcp-tool-list" class="tool-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Usage -->
|
||||
<div class="panel">
|
||||
<h3>Usage (1h)</h3>
|
||||
<div class="mcp-status">
|
||||
@@ -166,24 +189,6 @@
|
||||
<div id="tab-fs" class="tab-filesystem"></div>
|
||||
</div>
|
||||
|
||||
<!-- WebGPU Status -->
|
||||
<div class="panel">
|
||||
<h3>WebGPU AI</h3>
|
||||
<div class="gpu-status">
|
||||
<div class="status-row">
|
||||
<span>Status</span>
|
||||
<span id="gpu-status" class="status-value">
|
||||
<span class="status-indicator warning"></span>
|
||||
Checking...
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>Model</span>
|
||||
<span id="gpu-model" class="status-value mono">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button id="launch-agent" class="action-btn">
|
||||
@@ -199,8 +204,8 @@
|
||||
<!-- Settings Tab -->
|
||||
<div id="tab-settings" class="tab-content hidden">
|
||||
<div class="settings-scroll">
|
||||
<!-- Account -->
|
||||
<div class="panel" id="account-panel">
|
||||
<!-- Account (shown when authenticated) -->
|
||||
<div class="panel hidden" id="account-panel">
|
||||
<h3>Account</h3>
|
||||
<div class="user-profile">
|
||||
<img id="user-avatar" class="user-avatar" src="" alt="">
|
||||
@@ -227,6 +232,10 @@
|
||||
<span>ZAP Ports</span>
|
||||
<input type="text" id="zap-ports-setting" value="9999,9998,9997,9996,9995" placeholder="Comma-separated">
|
||||
</label>
|
||||
<label class="setting-item">
|
||||
<span>Node Port</span>
|
||||
<input type="number" id="node-port-setting" value="52415" min="1024" max="65535">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- AI Settings -->
|
||||
@@ -301,6 +310,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="sidebar-footer">
|
||||
<a href="https://hanzo.ai" target="_blank" class="footer-brand" title="hanzo.ai">
|
||||
<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" width="14" height="14">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span>hanzo.ai</span>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="sidebar.js"></script>
|
||||
|
||||
@@ -30,6 +30,7 @@ class HanzoSidebar {
|
||||
this.requestEpoch = 0;
|
||||
this.lastInputTokens = 0;
|
||||
this.lastOutputTokens = 0;
|
||||
this.authInFlight = false;
|
||||
this.refreshTabFilesystemDebounced = debounce(() => {
|
||||
if (this.currentTab === 'tools') {
|
||||
void this.refreshTabFilesystem();
|
||||
@@ -52,8 +53,6 @@ class HanzoSidebar {
|
||||
authSection: document.getElementById('auth-section'),
|
||||
authBtn: document.getElementById('auth-btn'),
|
||||
tabBar: document.getElementById('tab-bar'),
|
||||
userBadge: document.getElementById('user-badge'),
|
||||
headerAvatar: document.getElementById('header-avatar'),
|
||||
|
||||
// Chat
|
||||
tabChat: document.getElementById('tab-chat'),
|
||||
@@ -70,13 +69,19 @@ class HanzoSidebar {
|
||||
mcpStatus: document.getElementById('mcp-status'),
|
||||
mcpTools: document.getElementById('mcp-tools'),
|
||||
mcpToolList: document.getElementById('mcp-tool-list'),
|
||||
builtinToolList: document.getElementById('builtin-tool-list'),
|
||||
toolCountBadge: document.getElementById('tool-count-badge'),
|
||||
mcpServerList: document.getElementById('mcp-server-list'),
|
||||
mcpServerCount: document.getElementById('mcp-server-count'),
|
||||
agentCount: document.getElementById('agent-count'),
|
||||
agentList: document.getElementById('agent-list'),
|
||||
tabFs: document.getElementById('tab-fs'),
|
||||
refreshTabs: document.getElementById('refresh-tabs'),
|
||||
gpuStatus: document.getElementById('gpu-status'),
|
||||
gpuModel: document.getElementById('gpu-model'),
|
||||
launchAgent: document.getElementById('launch-agent'),
|
||||
hanzoNodeStatus: document.getElementById('hanzo-node-status'),
|
||||
hanzoMcpStatus: document.getElementById('hanzo-mcp-status'),
|
||||
startHanzoNode: document.getElementById('start-hanzo-node'),
|
||||
startHanzoMcp: document.getElementById('start-hanzo-mcp'),
|
||||
usageChatRequests: document.getElementById('usage-chat-requests'),
|
||||
usageInputTokens: document.getElementById('usage-input-tokens'),
|
||||
usageOutputTokens: document.getElementById('usage-output-tokens'),
|
||||
@@ -85,6 +90,7 @@ class HanzoSidebar {
|
||||
|
||||
// Settings
|
||||
tabSettings: document.getElementById('tab-settings'),
|
||||
accountPanel: document.getElementById('account-panel'),
|
||||
userAvatar: document.getElementById('user-avatar'),
|
||||
userName: document.getElementById('user-name'),
|
||||
userEmail: document.getElementById('user-email'),
|
||||
@@ -101,8 +107,20 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Auth
|
||||
this.el.authBtn.addEventListener('click', () => this.login());
|
||||
// Auth (delegated so lazy-mounted React login card keeps working)
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (target.closest('#auth-btn')) {
|
||||
event.preventDefault();
|
||||
void this.login();
|
||||
return;
|
||||
}
|
||||
if (target.closest('#signup-btn')) {
|
||||
event.preventDefault();
|
||||
void this.signup();
|
||||
}
|
||||
});
|
||||
|
||||
// Tabs
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
@@ -129,6 +147,8 @@ class HanzoSidebar {
|
||||
// Tools
|
||||
this.el.refreshTabs.addEventListener('click', () => this.refreshTabFilesystem());
|
||||
this.el.launchAgent.addEventListener('click', () => this.showAgentLauncher());
|
||||
this.el.startHanzoNode?.addEventListener('click', () => this.startHanzoService('node'));
|
||||
this.el.startHanzoMcp?.addEventListener('click', () => this.startHanzoService('bot'));
|
||||
|
||||
// Settings
|
||||
this.el.logoutBtn.addEventListener('click', () => this.logout());
|
||||
@@ -172,8 +192,14 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
async login() {
|
||||
this.el.authBtn.disabled = true;
|
||||
this.el.authBtn.textContent = 'Signing in...';
|
||||
if (this.authInFlight) return;
|
||||
this.authInFlight = true;
|
||||
|
||||
const authBtn = document.getElementById('auth-btn') as HTMLButtonElement | null;
|
||||
if (authBtn) {
|
||||
authBtn.disabled = true;
|
||||
authBtn.textContent = 'Signing in...';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'auth.login' });
|
||||
@@ -186,16 +212,30 @@ class HanzoSidebar {
|
||||
} catch (error) {
|
||||
this.showError('Sign in failed');
|
||||
} finally {
|
||||
this.el.authBtn.disabled = false;
|
||||
this.el.authBtn.textContent = 'Sign in with Hanzo';
|
||||
this.authInFlight = false;
|
||||
const latestAuthBtn = document.getElementById('auth-btn') as HTMLButtonElement | null;
|
||||
if (latestAuthBtn) {
|
||||
latestAuthBtn.disabled = false;
|
||||
latestAuthBtn.textContent = 'Sign in with Hanzo';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async signup() {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'auth.signup' });
|
||||
if (!response?.success) {
|
||||
this.showError(response?.error || 'Unable to open signup');
|
||||
} else {
|
||||
this.showNotification('Signup opened in a new tab');
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await chrome.runtime.sendMessage({ action: 'auth.logout' });
|
||||
this.el.userBadge.classList.add('hidden');
|
||||
this.el.userName.textContent = '';
|
||||
this.el.userEmail.textContent = '';
|
||||
if (this.el.userName) this.el.userName.textContent = '';
|
||||
if (this.el.userEmail) this.el.userEmail.textContent = '';
|
||||
if (this.el.userAvatar) this.el.userAvatar.src = '';
|
||||
if (this.el.accountPanel) this.el.accountPanel.classList.add('hidden');
|
||||
this.showChatLoginPrompt();
|
||||
this.switchTab('tools');
|
||||
}
|
||||
@@ -204,13 +244,20 @@ class HanzoSidebar {
|
||||
this.authenticated = true;
|
||||
if (user) {
|
||||
const avatar = user.picture || user.avatar || '';
|
||||
if (avatar) {
|
||||
this.el.headerAvatar.src = avatar;
|
||||
this.el.userBadge.classList.remove('hidden');
|
||||
if (avatar && this.el.userAvatar) {
|
||||
this.el.userAvatar.src = avatar;
|
||||
this.el.userAvatar.style.display = '';
|
||||
}
|
||||
if (this.el.userName) {
|
||||
this.el.userName.textContent = user.name || user.displayName || user.sub || 'User';
|
||||
}
|
||||
if (this.el.userEmail) {
|
||||
this.el.userEmail.textContent = user.email || '';
|
||||
}
|
||||
// Show the account panel
|
||||
if (this.el.accountPanel) {
|
||||
this.el.accountPanel.classList.remove('hidden');
|
||||
}
|
||||
this.el.userName.textContent = user.name || user.displayName || 'User';
|
||||
this.el.userEmail.textContent = user.email || '';
|
||||
}
|
||||
this.loadModels();
|
||||
this.loadConversation();
|
||||
@@ -223,6 +270,8 @@ class HanzoSidebar {
|
||||
if (chatLogin) chatLogin.classList.remove('hidden');
|
||||
const chatComposer = document.getElementById('chat-composer');
|
||||
if (chatComposer) chatComposer.classList.add('hidden');
|
||||
// Hide account panel when not authenticated
|
||||
if (this.el.accountPanel) this.el.accountPanel.classList.add('hidden');
|
||||
}
|
||||
|
||||
hideChatLoginPrompt() {
|
||||
@@ -283,9 +332,10 @@ class HanzoSidebar {
|
||||
ensureToolsInitialized() {
|
||||
if (this.toolsInitialized) return;
|
||||
this.toolsInitialized = true;
|
||||
this.renderBuiltinTools();
|
||||
void this.connectToMCP();
|
||||
void this.refreshTabFilesystem();
|
||||
void this.checkWebGPU();
|
||||
void this.checkHanzoServices();
|
||||
void this.refreshUsageMetrics();
|
||||
}
|
||||
|
||||
@@ -321,11 +371,21 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const text = this.el.chatInput.value.trim();
|
||||
const text = this.el.chatInput?.value?.trim();
|
||||
if (!text) return;
|
||||
if (!this.authenticated) {
|
||||
this.showError('Please sign in to use chat');
|
||||
return;
|
||||
// Double-check auth status in case it was set externally
|
||||
try {
|
||||
const status = await chrome.runtime.sendMessage({ action: 'auth.status' });
|
||||
if (status?.success && status.authenticated) {
|
||||
this.authenticated = true;
|
||||
if (status.user) this.setUser(status.user);
|
||||
}
|
||||
} catch {}
|
||||
if (!this.authenticated) {
|
||||
this.showError('Please sign in to use chat');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.streaming && this.streamController) {
|
||||
@@ -719,17 +779,75 @@ class HanzoSidebar {
|
||||
this.el.mcpTools.textContent = toolCount;
|
||||
}
|
||||
|
||||
// Built-in @hanzo/mcp tool categories (always available)
|
||||
static BUILTIN_TOOLS = [
|
||||
{ category: 'File Operations', tools: ['read_file', 'write_file', 'list_files', 'get_file_info', 'directory_tree'] },
|
||||
{ category: 'Search', tools: ['grep', 'find_files', 'search'] },
|
||||
{ category: 'Shell', tools: ['bash', 'run_command', 'run_background', 'list_processes', 'kill_process'] },
|
||||
{ category: 'Edit', tools: ['edit_file', 'multi_edit', 'create_file', 'delete_file', 'move_file'] },
|
||||
{ category: 'AI Tools', tools: ['think', 'critic', 'consensus', 'agent'] },
|
||||
{ category: 'AST Search', tools: ['ast_search', 'find_symbol', 'analyze_dependencies'] },
|
||||
{ category: 'Vector Search', tools: ['vector_index', 'vector_search', 'vector_stats'] },
|
||||
{ category: 'Todo', tools: ['todo_add', 'todo_list', 'todo_update', 'todo_delete', 'todo_stats'] },
|
||||
{ category: 'Modes', tools: ['mode_switch', 'mode_list', 'preset_select', 'preset_list', 'shortcut'] },
|
||||
];
|
||||
|
||||
renderBuiltinTools() {
|
||||
if (!this.el.builtinToolList) return;
|
||||
|
||||
const totalBuiltin = HanzoSidebar.BUILTIN_TOOLS.reduce((sum, c) => sum + c.tools.length, 0);
|
||||
|
||||
this.el.builtinToolList.innerHTML = HanzoSidebar.BUILTIN_TOOLS.map(cat =>
|
||||
`<div class="tool-category">` +
|
||||
`<span class="tool-category-name">${this.escapeHtml(cat.category)}</span> ` +
|
||||
`<span class="tool-category-tools">${cat.tools.join(', ')}</span>` +
|
||||
`</div>`
|
||||
).join('');
|
||||
|
||||
this.updateToolCount(totalBuiltin);
|
||||
}
|
||||
|
||||
updateToolCount(builtinCount: number) {
|
||||
const zapCount = parseInt(this.el.mcpTools?.textContent || '0') || 0;
|
||||
const total = builtinCount + zapCount;
|
||||
if (this.el.toolCountBadge) this.el.toolCountBadge.textContent = String(total);
|
||||
}
|
||||
|
||||
updateMCPToolList(tools) {
|
||||
if (!this.el.mcpToolList) return;
|
||||
if (!tools || !tools.length) {
|
||||
this.el.mcpToolList.textContent = 'No tools discovered yet.';
|
||||
this.el.mcpToolList.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const names = Array.from(new Set(tools.map((tool) => tool.name))).sort();
|
||||
const preview = names.slice(0, 20);
|
||||
const suffix = names.length > preview.length ? `\n... +${names.length - preview.length} more` : '';
|
||||
this.el.mcpToolList.textContent = `${preview.join('\n')}${suffix}`;
|
||||
this.el.mcpToolList.textContent = `ZAP: ${preview.join(', ')}${suffix}`;
|
||||
|
||||
// Update total count
|
||||
const builtinCount = HanzoSidebar.BUILTIN_TOOLS.reduce((sum, c) => sum + c.tools.length, 0);
|
||||
this.updateToolCount(builtinCount);
|
||||
}
|
||||
|
||||
updateMCPServerList(mcps) {
|
||||
if (!this.el.mcpServerList) return;
|
||||
if (this.el.mcpServerCount) this.el.mcpServerCount.textContent = String(mcps?.length || 0);
|
||||
|
||||
if (!mcps || !mcps.length) {
|
||||
this.el.mcpServerList.innerHTML = '<div class="empty-state">No MCP servers connected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.el.mcpServerList.innerHTML = mcps.map(mcp => `
|
||||
<div class="mcp-server-item">
|
||||
<div class="mcp-server-info">
|
||||
<span class="status-indicator connected"></span>
|
||||
<span class="mcp-server-name">${this.escapeHtml(mcp.name || mcp.id)}</span>
|
||||
</div>
|
||||
<span class="mcp-server-tools">${(mcp.tools?.length || 0)} tools</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async refreshUsageMetrics() {
|
||||
@@ -771,22 +889,79 @@ class HanzoSidebar {
|
||||
return title.replace(/[^a-zA-Z0-9-_]/g, '_').toLowerCase().substring(0, 30);
|
||||
}
|
||||
|
||||
async checkWebGPU() {
|
||||
async checkHanzoServices() {
|
||||
// Check hanzo node (default port 52415)
|
||||
const nodePort = parseInt(document.getElementById('node-port-setting')?.value || '52415') || 52415;
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'checkWebGPU' });
|
||||
if (response?.available) {
|
||||
this.el.gpuStatus.innerHTML = `
|
||||
<span class="status-indicator connected"></span>
|
||||
Available
|
||||
`;
|
||||
this.el.gpuModel.textContent = response.adapter || 'Unknown';
|
||||
const resp = await fetch(`http://localhost:${nodePort}/health`, { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) {
|
||||
if (this.el.hanzoNodeStatus) this.el.hanzoNodeStatus.innerHTML = `<span class="status-indicator connected"></span> Running`;
|
||||
if (this.el.startHanzoNode) { this.el.startHanzoNode.textContent = 'Running'; this.el.startHanzoNode.disabled = true; }
|
||||
} else {
|
||||
this.el.gpuStatus.innerHTML = `
|
||||
<span class="status-indicator disconnected"></span>
|
||||
Not Available
|
||||
`;
|
||||
if (this.el.hanzoNodeStatus) this.el.hanzoNodeStatus.innerHTML = `<span class="status-indicator disconnected"></span> Stopped`;
|
||||
if (this.el.startHanzoNode) this.el.startHanzoNode.disabled = false;
|
||||
}
|
||||
} catch {
|
||||
if (this.el.hanzoNodeStatus) this.el.hanzoNodeStatus.innerHTML = `<span class="status-indicator disconnected"></span> Stopped`;
|
||||
if (this.el.startHanzoNode) this.el.startHanzoNode.disabled = false;
|
||||
}
|
||||
|
||||
// Check hanzo bot/MCP via ZAP status
|
||||
try {
|
||||
const zapResp = await chrome.runtime.sendMessage({ action: 'zap.status' });
|
||||
const hasTools = zapResp?.success && zapResp.zap?.connected;
|
||||
if (this.el.hanzoMcpStatus) {
|
||||
this.el.hanzoMcpStatus.innerHTML = hasTools
|
||||
? `<span class="status-indicator connected"></span> Running`
|
||||
: `<span class="status-indicator disconnected"></span> Stopped`;
|
||||
}
|
||||
if (this.el.startHanzoMcp) {
|
||||
this.el.startHanzoMcp.disabled = !!hasTools;
|
||||
if (hasTools) this.el.startHanzoMcp.textContent = 'Running';
|
||||
}
|
||||
|
||||
// Update connected MCP servers list
|
||||
if (zapResp?.success && zapResp.zap?.mcps?.length) {
|
||||
this.updateMCPServerList(zapResp.zap.mcps);
|
||||
} else {
|
||||
this.updateMCPServerList([]);
|
||||
}
|
||||
} catch {
|
||||
if (this.el.hanzoMcpStatus) {
|
||||
this.el.hanzoMcpStatus.innerHTML = `<span class="status-indicator disconnected"></span> Stopped`;
|
||||
}
|
||||
this.updateMCPServerList([]);
|
||||
}
|
||||
}
|
||||
|
||||
async startHanzoService(service: string) {
|
||||
const commands = {
|
||||
node: 'hanzo node --port 52415',
|
||||
bot: 'npx @hanzo/mcp serve',
|
||||
};
|
||||
const labels = { node: 'Hanzo Node', bot: 'Hanzo Bot' };
|
||||
const cmd = commands[service] || commands.node;
|
||||
const label = labels[service] || service;
|
||||
|
||||
// Try native messaging or CDP bridge first
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
action: 'cdp.exec',
|
||||
command: cmd,
|
||||
});
|
||||
if (response?.success) {
|
||||
this.showNotification(`Starting ${label}...`);
|
||||
setTimeout(() => this.checkHanzoServices(), 3000);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback: show the command to run with a copy-friendly notification
|
||||
this.showNotification(`Run in terminal: ${cmd}`);
|
||||
|
||||
// Also try to open docs
|
||||
const docsSection = service === 'bot' ? 'mcp' : service;
|
||||
chrome.tabs.create({ url: `https://docs.hanzo.ai/cli#${docsSection}` });
|
||||
}
|
||||
|
||||
updateAgentList() {
|
||||
@@ -944,6 +1119,7 @@ class HanzoSidebar {
|
||||
'mcpPort',
|
||||
'cdpPort',
|
||||
'zapPorts',
|
||||
'nodePort',
|
||||
'hanzo_default_model',
|
||||
'hanzo_rag_endpoint',
|
||||
'hanzo_rag_api_key',
|
||||
@@ -957,10 +1133,12 @@ class HanzoSidebar {
|
||||
const mcpPort = document.getElementById('mcp-port-setting');
|
||||
const cdpPort = document.getElementById('cdp-port-setting');
|
||||
const zapPorts = document.getElementById('zap-ports-setting');
|
||||
const nodePort = document.getElementById('node-port-setting');
|
||||
|
||||
if (mcpPort) mcpPort.value = result.mcpPort || 3001;
|
||||
if (cdpPort) cdpPort.value = result.cdpPort || 9223;
|
||||
if (zapPorts) zapPorts.value = (result.zapPorts || [9999,9998,9997,9996,9995]).join(',');
|
||||
if (nodePort) nodePort.value = result.nodePort || 52415;
|
||||
if (result.hanzo_default_model && this.el.defaultModel) {
|
||||
this.el.defaultModel.value = result.hanzo_default_model;
|
||||
}
|
||||
@@ -983,9 +1161,12 @@ class HanzoSidebar {
|
||||
const zapPorts = (document.getElementById('zap-ports-setting')?.value || '')
|
||||
.split(',').map(p => parseInt(p.trim())).filter(p => p > 0 && p < 65536);
|
||||
|
||||
const nodePort = parseInt(document.getElementById('node-port-setting')?.value) || 52415;
|
||||
|
||||
await chrome.storage.local.set({
|
||||
mcpPort,
|
||||
cdpPort,
|
||||
nodePort,
|
||||
zapPorts: zapPorts.length ? zapPorts : [9999,9998,9997,9996,9995],
|
||||
hanzo_default_model: this.el.defaultModel?.value || 'auto',
|
||||
'safe-mode': document.getElementById('safe-mode')?.checked,
|
||||
@@ -1001,6 +1182,21 @@ class HanzoSidebar {
|
||||
hanzo_chat_tab_context_enabled: !!this.el.tabContextEnabled?.checked,
|
||||
});
|
||||
|
||||
// Sync settings to ~/.hanzo/extension/config.json via CDP bridge
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'settings.sync',
|
||||
settings: {
|
||||
mcpPort,
|
||||
cdpPort,
|
||||
nodePort,
|
||||
zapPorts: zapPorts.length ? zapPorts : [9999,9998,9997,9996,9995],
|
||||
defaultModel: this.el.defaultModel?.value || 'auto',
|
||||
safeMode: !!document.getElementById('safe-mode')?.checked,
|
||||
maxAgents: parseInt(document.getElementById('max-agents')?.value) || 3,
|
||||
enableWebGPU: !!document.getElementById('enable-webgpu')?.checked,
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
this.showNotification('Settings saved');
|
||||
}
|
||||
|
||||
@@ -1015,6 +1211,7 @@ class HanzoSidebar {
|
||||
setInterval(() => {
|
||||
if (this.currentTab !== 'tools') return;
|
||||
void this.connectToMCP();
|
||||
void this.checkHanzoServices();
|
||||
void this.refreshUsageMetrics();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
@@ -21,9 +21,13 @@ describe('Claude Code Browser Extension Integration', () => {
|
||||
await new Promise(resolve => ws.on('open', resolve));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
ws.close();
|
||||
server.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
// Fallback if close callback never fires
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('React source-map integration', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/dxt",
|
||||
"version": "1.5.7",
|
||||
"version": "1.7.14",
|
||||
"description": "Hanzo AI DXT Bundle",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -11,4 +11,4 @@
|
||||
"archiver": "^7.0.1"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ pluginGroup = ai.hanzo
|
||||
pluginName = Hanzo AI
|
||||
pluginRepositoryUrl = https://github.com/hanzoai/hanzo-ai-jetbrains
|
||||
# SemVer format -> https://semver.org
|
||||
pluginVersion = 0.1.0
|
||||
pluginVersion = 1.7.11
|
||||
|
||||
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
|
||||
pluginSinceBuild = 233
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/cli-tools",
|
||||
"version": "1.5.7",
|
||||
"version": "1.7.14",
|
||||
"description": "Hanzo AI CLI Tools Integration",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -34,4 +34,4 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import * as crypto from 'crypto';
|
||||
|
||||
export interface HanzoAuthConfig {
|
||||
apiUrl: string;
|
||||
iamUrl: string;
|
||||
iamUrl: string; // Casdoor API (iam.hanzo.ai)
|
||||
iamLoginUrl: string; // Login UI (hanzo.id)
|
||||
clientId: string;
|
||||
scope: string;
|
||||
configPath?: string;
|
||||
@@ -44,6 +45,7 @@ export class HanzoAuth extends EventEmitter {
|
||||
this.config = {
|
||||
apiUrl: config.apiUrl || 'https://api.hanzo.ai',
|
||||
iamUrl: config.iamUrl || 'https://iam.hanzo.ai',
|
||||
iamLoginUrl: config.iamLoginUrl || 'https://hanzo.id',
|
||||
clientId: config.clientId || 'hanzo-dev-cli',
|
||||
scope: config.scope || 'api:access tools:manage',
|
||||
configPath: config.configPath || path.join(os.homedir(), '.hanzo')
|
||||
@@ -100,9 +102,8 @@ export class HanzoAuth extends EventEmitter {
|
||||
const url = new URL(req.url!, `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === '/callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const returnedState = url.searchParams.get('state');
|
||||
|
||||
|
||||
if (returnedState !== state) {
|
||||
res.writeHead(400);
|
||||
res.end('Invalid state parameter');
|
||||
@@ -110,55 +111,69 @@ export class HanzoAuth extends EventEmitter {
|
||||
reject(new Error('Invalid state parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
// Exchange code for tokens
|
||||
try {
|
||||
await this.exchangeCodeForTokens(code, codeVerifier);
|
||||
|
||||
// Success response
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Hanzo Dev - Login Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a1a; color: white; }
|
||||
.container { text-align: center; }
|
||||
.success { color: #4ade80; font-size: 48px; margin-bottom: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success">✓</div>
|
||||
<h1>Login Successful!</h1>
|
||||
<p>You can now close this window and return to your terminal.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
this.server?.close();
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
res.writeHead(500);
|
||||
res.end('Failed to exchange code for tokens');
|
||||
this.server?.close();
|
||||
reject(error);
|
||||
}
|
||||
|
||||
// Implicit flow: access_token comes directly in URL params
|
||||
const accessToken = url.searchParams.get('access_token');
|
||||
// Code flow fallback
|
||||
const code = url.searchParams.get('code');
|
||||
|
||||
const successHtml = `<html><head><title>Hanzo Dev - Login Successful</title>
|
||||
<style>body{font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#1a1a1a;color:white}.container{text-align:center}.success{color:#4ade80;font-size:48px;margin-bottom:20px}</style>
|
||||
</head><body><div class="container"><div class="success">✓</div><h1>Login Successful!</h1>
|
||||
<p>You can now close this window.</p></div>
|
||||
<script>setTimeout(()=>window.close(),3000)</script></body></html>`;
|
||||
|
||||
if (accessToken) {
|
||||
// Implicit flow — token received directly
|
||||
const refreshToken = url.searchParams.get('refresh_token');
|
||||
const expiresIn = url.searchParams.get('expires_in');
|
||||
|
||||
this.credentials = {
|
||||
accessToken,
|
||||
refreshToken: refreshToken || undefined,
|
||||
expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : Date.now() + 168 * 3600 * 1000,
|
||||
...this.credentials,
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(successHtml);
|
||||
this.server?.close();
|
||||
|
||||
// Fetch user info and save
|
||||
this.fetchUserInfo().then(() => this.syncAPIKeys()).then(() => {
|
||||
this.saveCredentials();
|
||||
this.emit('login:success', this.credentials);
|
||||
}).catch(() => {});
|
||||
|
||||
resolve(true);
|
||||
} else if (code) {
|
||||
// Authorization code flow fallback
|
||||
(async () => {
|
||||
try {
|
||||
await this.exchangeCodeForTokens(code, codeVerifier);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(successHtml);
|
||||
this.server?.close();
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
res.writeHead(500);
|
||||
res.end('Failed to exchange code for tokens');
|
||||
this.server?.close();
|
||||
reject(error);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('No authorization code received');
|
||||
res.end('No token or authorization code received');
|
||||
this.server?.close();
|
||||
reject(new Error('No authorization code received'));
|
||||
reject(new Error('No token received'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(port, () => {
|
||||
// Build authorization URL
|
||||
const authUrl = new URL('/oauth/authorize', this.config.iamUrl);
|
||||
// Authorization Code + PKCE (OAuth 2.1 standard)
|
||||
const authUrl = new URL('/login/oauth/authorize', this.config.iamLoginUrl);
|
||||
authUrl.searchParams.set('client_id', this.config.clientId);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`);
|
||||
@@ -185,7 +200,7 @@ export class HanzoAuth extends EventEmitter {
|
||||
}
|
||||
|
||||
private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise<void> {
|
||||
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
|
||||
const response = await fetch(`${this.config.iamUrl}/api/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -224,16 +239,26 @@ export class HanzoAuth extends EventEmitter {
|
||||
|
||||
private async fetchUserInfo(): Promise<void> {
|
||||
if (!this.credentials?.accessToken) return;
|
||||
|
||||
const response = await fetch(`${this.config.iamUrl}/api/user`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.credentials.accessToken}`
|
||||
const headers = { 'Authorization': `Bearer ${this.credentials.accessToken}` };
|
||||
|
||||
// /api/get-account returns full profile; /api/userinfo only returns sub
|
||||
try {
|
||||
const acctResp = await fetch(`${this.config.iamUrl}/api/get-account`, { headers });
|
||||
if (acctResp.ok) {
|
||||
const acctJson = await acctResp.json() as any;
|
||||
const acct = acctJson.data || acctJson;
|
||||
if (acct.name || acct.email) {
|
||||
this.credentials.userId = acct.id || acct.sub;
|
||||
this.credentials.email = acct.email;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch { /* fall through */ }
|
||||
|
||||
const response = await fetch(`${this.config.iamUrl}/api/userinfo`, { headers });
|
||||
if (response.ok) {
|
||||
const user = await response.json() as any;
|
||||
this.credentials.userId = user.id;
|
||||
this.credentials.userId = user.id || user.sub;
|
||||
this.credentials.email = user.email;
|
||||
}
|
||||
}
|
||||
@@ -293,7 +318,7 @@ export class HanzoAuth extends EventEmitter {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
|
||||
const response = await fetch(`${this.config.iamUrl}/api/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/index.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -1,399 +1,399 @@
|
||||
{
|
||||
"name": "@hanzo/extension",
|
||||
"displayName": "Hanzo AI",
|
||||
"description": "The ultimate meta AI development platform. Manage and run all LLMs and CLI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface. Features MCP integration, async execution, git worktrees, and universal context sync.",
|
||||
"version": "1.7.1",
|
||||
"publisher": "hanzo-ai",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/dev.git"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"keywords": [
|
||||
"productivity",
|
||||
"AI",
|
||||
"IDE",
|
||||
"project management",
|
||||
"context management",
|
||||
"change tracking",
|
||||
"knowledge base",
|
||||
"documentation",
|
||||
"specification",
|
||||
"analysis"
|
||||
],
|
||||
"icon": "images/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#C80000",
|
||||
"theme": "dark"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onStartupFinished",
|
||||
"onCommand:hanzo.openManager",
|
||||
"onCommand:hanzo.openWelcomeGuide",
|
||||
"onCommand:hanzo.reanalyzeProject",
|
||||
"onCommand:hanzo.triggerReminder",
|
||||
"onCommand:hanzo.login",
|
||||
"onCommand:hanzo.logout",
|
||||
"onCommand:hanzo.debug.authState",
|
||||
"onCommand:hanzo.debug.clearAuth",
|
||||
"onCommand:hanzo.checkMetrics",
|
||||
"onCommand:hanzo.resetMetrics"
|
||||
],
|
||||
"main": "./out/extension.js",
|
||||
"contributes": {
|
||||
"chatParticipants": [
|
||||
{
|
||||
"id": "hanzo",
|
||||
"name": "Hanzo",
|
||||
"description": "Ultimate AI engineering toolkit: 200+ LLMs, 4000+ MCP servers, 53+ dev tools",
|
||||
"isSticky": true
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"command": "hanzo.openManager",
|
||||
"title": "Hanzo: Open Project Manager"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.openWelcomeGuide",
|
||||
"title": "Hanzo: View Getting Started Guide"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.reanalyzeProject",
|
||||
"title": "Hanzo: Analyze Project",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.triggerReminder",
|
||||
"title": "Hanzo: Show Reminder",
|
||||
"icon": "$(bell)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.checkMetrics",
|
||||
"title": "Hanzo Debug: Check Impact Metrics",
|
||||
"icon": "$(graph)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.resetMetrics",
|
||||
"title": "Hanzo Debug: Reset Impact Metrics",
|
||||
"icon": "$(trash)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.login",
|
||||
"title": "Hanzo: Login"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.logout",
|
||||
"title": "Hanzo: Logout"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.debug.authState",
|
||||
"title": "Hanzo Debug: Check Auth State",
|
||||
"enablement": "isDevelopment"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.debug.clearAuth",
|
||||
"title": "Hanzo Debug: Clear Auth Data",
|
||||
"enablement": "isDevelopment"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Hanzo AI Context Manager",
|
||||
"properties": {
|
||||
"hanzo.ide": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cursor",
|
||||
"copilot",
|
||||
"continue",
|
||||
"codium"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Using Cursor IDE (.cursorrules)",
|
||||
"Using GitHub Copilot (.github/copilot-instructions.md)",
|
||||
"Using Continue (.continuerules)",
|
||||
"Using Codium (.windsurfrules)"
|
||||
],
|
||||
"default": "cursor",
|
||||
"description": "Select which IDE to generate rules for"
|
||||
},
|
||||
"hanzo.mcp.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable Model Context Protocol (MCP) server integration"
|
||||
},
|
||||
"hanzo.mcp.transport": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stdio",
|
||||
"tcp"
|
||||
],
|
||||
"default": "stdio",
|
||||
"description": "Transport method for MCP server communication"
|
||||
},
|
||||
"hanzo.mcp.port": {
|
||||
"type": "number",
|
||||
"default": 3000,
|
||||
"description": "Port for MCP server when using TCP transport"
|
||||
},
|
||||
"hanzo.mcp.backend": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"python",
|
||||
"typescript",
|
||||
"rust",
|
||||
"local-node"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Auto-detect: prefer Python (uvx) if available, fallback to TypeScript",
|
||||
"Python MCP via uvx (recommended, most comprehensive tools)",
|
||||
"Built-in TypeScript MCP server",
|
||||
"Rust MCP binary (hanzo-mcp)",
|
||||
"Connect to local hanzod node"
|
||||
],
|
||||
"default": "auto",
|
||||
"description": "MCP backend to use. Python via uvx is recommended for the most comprehensive tool support."
|
||||
},
|
||||
"hanzo.mcp.pythonCommand": {
|
||||
"type": "string",
|
||||
"default": "uvx hanzo-mcp",
|
||||
"description": "Command to run Python MCP server (e.g., 'uvx hanzo-mcp' or 'python -m hanzo_mcp')"
|
||||
},
|
||||
"hanzo.mcp.rustBinary": {
|
||||
"type": "string",
|
||||
"default": "hanzo-mcp",
|
||||
"description": "Path to Rust MCP binary (when backend is 'rust')"
|
||||
},
|
||||
"hanzo.mcp.localNodeUrl": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:8080",
|
||||
"description": "URL of local hanzod node (when backend is 'local-node')"
|
||||
},
|
||||
"hanzo.mcp.allowedPaths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "Paths that MCP tools are allowed to access"
|
||||
},
|
||||
"hanzo.mcp.disableWriteTools": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable all write operations in MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disableSearchTools": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable all search operations in MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disableBrowserTool": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable the built-in browser tool (useful when using Antigravity's native browser)"
|
||||
},
|
||||
"hanzo.mcp.enabledTools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "List of explicitly enabled MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disabledTools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "List of explicitly disabled MCP tools"
|
||||
},
|
||||
"hanzo.api.endpoint": {
|
||||
"type": "string",
|
||||
"default": "https://api.hanzo.ai/ext/v1",
|
||||
"description": "API endpoint for Hanzo services"
|
||||
},
|
||||
"hanzo.debug": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable debug logging"
|
||||
},
|
||||
"hanzo.llm.provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"hanzo",
|
||||
"lmstudio",
|
||||
"ollama",
|
||||
"openai",
|
||||
"anthropic"
|
||||
],
|
||||
"default": "hanzo",
|
||||
"description": "LLM provider to use for AI features"
|
||||
},
|
||||
"hanzo.llm.hanzo.apiKey": {
|
||||
"type": "string",
|
||||
"description": "API key for Hanzo AI Gateway"
|
||||
},
|
||||
"hanzo.llm.lmstudio.endpoint": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:1234/v1",
|
||||
"description": "LM Studio API endpoint"
|
||||
},
|
||||
"hanzo.llm.lmstudio.model": {
|
||||
"type": "string",
|
||||
"description": "Model to use in LM Studio"
|
||||
},
|
||||
"hanzo.llm.ollama.endpoint": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:11434",
|
||||
"description": "Ollama API endpoint"
|
||||
},
|
||||
"hanzo.llm.ollama.model": {
|
||||
"type": "string",
|
||||
"default": "llama2",
|
||||
"description": "Model to use in Ollama"
|
||||
},
|
||||
"hanzo.llm.openai.apiKey": {
|
||||
"type": "string",
|
||||
"description": "OpenAI API key"
|
||||
},
|
||||
"hanzo.llm.openai.model": {
|
||||
"type": "string",
|
||||
"default": "gpt-4",
|
||||
"description": "OpenAI model to use"
|
||||
},
|
||||
"hanzo.llm.anthropic.apiKey": {
|
||||
"type": "string",
|
||||
"description": "Anthropic API key"
|
||||
},
|
||||
"hanzo.llm.anthropic.model": {
|
||||
"type": "string",
|
||||
"default": "claude-3-opus-20240229",
|
||||
"description": "Anthropic model to use"
|
||||
}
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "hanzo.reanalyzeProject",
|
||||
"group": "navigation",
|
||||
"when": "resourceScheme != extension"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "node scripts/compile-main.js || true && npm run build:mcp",
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"pretest": "npm run compile && npm run lint",
|
||||
"lint": "eslint .",
|
||||
"test": "node ./out/test/runTest.js",
|
||||
"test:simple": "node test-simple.js",
|
||||
"test:pm": "cross-env MOCHA_TEST_FILE=\"ProjectManager Test Suite\" node ./out/test/runTest.js",
|
||||
"test:auth": "cross-env MOCHA_TEST_FILE=\"Auth and API Test Suite\" node ./out/test/runTest.js",
|
||||
"test:file-collection": "cross-env MOCHA_TEST_FILE=\"FileCollectionService Test Suite\" node ./out/test/runTest.js",
|
||||
"test:mcp": "cross-env MOCHA_TEST_FILE=\"MCP.*Tool Test Suite\" node ./out/test/runTest.js",
|
||||
"test:mcp-proxy": "cross-env MOCHA_TEST_FILE=\"MCP.*Proxy Test\" node ./out/test/runTest.js",
|
||||
"test:mcp-integration": "cross-env MOCHA_TEST_FILE=\"MCP Proxy Integration\" node ./out/test/runTest.js",
|
||||
"test:coverage": "c8 npm test",
|
||||
"test:vitest": "vitest",
|
||||
"test:vitest:coverage": "vitest --coverage",
|
||||
"test:vitest:run": "vitest run --coverage",
|
||||
"build:mcp": "node scripts/build-mcp-standalone.js",
|
||||
"build:claude-desktop": "npm run build:mcp && node scripts/build-claude-desktop.js",
|
||||
"build": "tsc -p ./",
|
||||
"build:dxt": "node scripts/build-dxt.js",
|
||||
"build:npm": "node scripts/build-mcp-npm.js",
|
||||
"build:cursor": "node scripts/build-cursor.js",
|
||||
"build:windsurf": "node scripts/build-windsurf.js",
|
||||
"build:all": "node scripts/build-all-platforms.js",
|
||||
"build:browser-extension": "node src/browser-extension/build.js",
|
||||
"package": "npm run build:all && sed -i '' 's/\"name\": \"@hanzo\\/extension\"/\"name\": \"hanzo-extension\"/' package.json && vsce package --no-dependencies; sed -i '' 's/\"name\": \"hanzo-extension\"/\"name\": \"@hanzo\\/extension\"/' package.json",
|
||||
"package:claude": "npm run build:claude-desktop",
|
||||
"package:dxt": "npm run build:dxt",
|
||||
"publish": "npm run package && npx vsce publish && ovsx publish",
|
||||
"publish:npm": "npm run build:claude-desktop && cd dist/claude-desktop && npm publish",
|
||||
"dev": "cross-env VSCODE_ENV=development npm run watch",
|
||||
"dev:mcp": "cross-env MCP_TRANSPORT=stdio node ./out/mcp-server-standalone.js",
|
||||
"start:prod": "cross-env VSCODE_ENV=production npm run watch",
|
||||
"preview": "npx serve . -p 3000",
|
||||
"deploy": "vercel --prod",
|
||||
"deploy:preview": "vercel"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/inquirer": "^9.0.8",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/sinonjs__fake-timers": "^8.1.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"@vscode/test-cli": "^0.0.11",
|
||||
"@vscode/test-electron": "^2.5.2",
|
||||
"@vscode/vsce": "^2.24.0",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"c8": "^10.1.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.25.6",
|
||||
"eslint": "^8.26.0",
|
||||
"glob": "^10.3.10",
|
||||
"jsdom": "^26.1.0",
|
||||
"mocha": "^11.0.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"sinon": "^21.0.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lancedb/lancedb": "^0.21.0",
|
||||
"@modelcontextprotocol/sdk": "^1.14.0",
|
||||
"@supabase/supabase-js": "^2.48.1",
|
||||
"@typescript-eslint/typescript-estree": "^8.35.1",
|
||||
"axios": "^1.6.2",
|
||||
"ignore": "^7.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimatch": "^10.0.1",
|
||||
"rxdb": "^16.15.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-javascript": "^0.23.1",
|
||||
"tree-sitter-typescript": "^0.23.2",
|
||||
"zod": "^3.25.71"
|
||||
},
|
||||
"__metadata": {
|
||||
"id": "fd0ca99f-75f0-4318-af8c-660c5e883c16",
|
||||
"publisherId": "c3a25647-333f-4954-a3a7-a5487b656f72",
|
||||
"publisherDisplayName": "hanzo-ai",
|
||||
"targetPlatform": "undefined",
|
||||
"isApplicationScoped": false,
|
||||
"isPreReleaseVersion": false,
|
||||
"hasPreReleaseVersion": false,
|
||||
"installedTimestamp": 1743812550788,
|
||||
"pinned": false,
|
||||
"preRelease": false,
|
||||
"source": "gallery",
|
||||
"size": 15245886
|
||||
}
|
||||
}
|
||||
"name": "@hanzo/extension",
|
||||
"displayName": "Hanzo AI",
|
||||
"description": "The ultimate meta AI development platform. Manage and run all LLMs and CLI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface. Features MCP integration, async execution, git worktrees, and universal context sync.",
|
||||
"version": "1.7.14",
|
||||
"publisher": "hanzo-ai",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/dev.git"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"keywords": [
|
||||
"productivity",
|
||||
"AI",
|
||||
"IDE",
|
||||
"project management",
|
||||
"context management",
|
||||
"change tracking",
|
||||
"knowledge base",
|
||||
"documentation",
|
||||
"specification",
|
||||
"analysis"
|
||||
],
|
||||
"icon": "images/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#C80000",
|
||||
"theme": "dark"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onStartupFinished",
|
||||
"onCommand:hanzo.openManager",
|
||||
"onCommand:hanzo.openWelcomeGuide",
|
||||
"onCommand:hanzo.reanalyzeProject",
|
||||
"onCommand:hanzo.triggerReminder",
|
||||
"onCommand:hanzo.login",
|
||||
"onCommand:hanzo.logout",
|
||||
"onCommand:hanzo.debug.authState",
|
||||
"onCommand:hanzo.debug.clearAuth",
|
||||
"onCommand:hanzo.checkMetrics",
|
||||
"onCommand:hanzo.resetMetrics"
|
||||
],
|
||||
"main": "./out/extension.js",
|
||||
"contributes": {
|
||||
"chatParticipants": [
|
||||
{
|
||||
"id": "hanzo",
|
||||
"name": "Hanzo",
|
||||
"description": "Ultimate AI engineering toolkit: 200+ LLMs, 4000+ MCP servers, 53+ dev tools",
|
||||
"isSticky": true
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"command": "hanzo.openManager",
|
||||
"title": "Hanzo: Open Project Manager"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.openWelcomeGuide",
|
||||
"title": "Hanzo: View Getting Started Guide"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.reanalyzeProject",
|
||||
"title": "Hanzo: Analyze Project",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.triggerReminder",
|
||||
"title": "Hanzo: Show Reminder",
|
||||
"icon": "$(bell)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.checkMetrics",
|
||||
"title": "Hanzo Debug: Check Impact Metrics",
|
||||
"icon": "$(graph)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.resetMetrics",
|
||||
"title": "Hanzo Debug: Reset Impact Metrics",
|
||||
"icon": "$(trash)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.login",
|
||||
"title": "Hanzo: Login"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.logout",
|
||||
"title": "Hanzo: Logout"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.debug.authState",
|
||||
"title": "Hanzo Debug: Check Auth State",
|
||||
"enablement": "isDevelopment"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.debug.clearAuth",
|
||||
"title": "Hanzo Debug: Clear Auth Data",
|
||||
"enablement": "isDevelopment"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Hanzo AI Context Manager",
|
||||
"properties": {
|
||||
"hanzo.ide": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cursor",
|
||||
"copilot",
|
||||
"continue",
|
||||
"codium"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Using Cursor IDE (.cursorrules)",
|
||||
"Using GitHub Copilot (.github/copilot-instructions.md)",
|
||||
"Using Continue (.continuerules)",
|
||||
"Using Codium (.windsurfrules)"
|
||||
],
|
||||
"default": "cursor",
|
||||
"description": "Select which IDE to generate rules for"
|
||||
},
|
||||
"hanzo.mcp.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable Model Context Protocol (MCP) server integration"
|
||||
},
|
||||
"hanzo.mcp.transport": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stdio",
|
||||
"tcp"
|
||||
],
|
||||
"default": "stdio",
|
||||
"description": "Transport method for MCP server communication"
|
||||
},
|
||||
"hanzo.mcp.port": {
|
||||
"type": "number",
|
||||
"default": 3000,
|
||||
"description": "Port for MCP server when using TCP transport"
|
||||
},
|
||||
"hanzo.mcp.backend": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"python",
|
||||
"typescript",
|
||||
"rust",
|
||||
"local-node"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Auto-detect: prefer Python (uvx) if available, fallback to TypeScript",
|
||||
"Python MCP via uvx (recommended, most comprehensive tools)",
|
||||
"Built-in TypeScript MCP server",
|
||||
"Rust MCP binary (hanzo-mcp)",
|
||||
"Connect to local hanzod node"
|
||||
],
|
||||
"default": "auto",
|
||||
"description": "MCP backend to use. Python via uvx is recommended for the most comprehensive tool support."
|
||||
},
|
||||
"hanzo.mcp.pythonCommand": {
|
||||
"type": "string",
|
||||
"default": "uvx hanzo-mcp",
|
||||
"description": "Command to run Python MCP server (e.g., 'uvx hanzo-mcp' or 'python -m hanzo_mcp')"
|
||||
},
|
||||
"hanzo.mcp.rustBinary": {
|
||||
"type": "string",
|
||||
"default": "hanzo-mcp",
|
||||
"description": "Path to Rust MCP binary (when backend is 'rust')"
|
||||
},
|
||||
"hanzo.mcp.localNodeUrl": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:8080",
|
||||
"description": "URL of local hanzod node (when backend is 'local-node')"
|
||||
},
|
||||
"hanzo.mcp.allowedPaths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "Paths that MCP tools are allowed to access"
|
||||
},
|
||||
"hanzo.mcp.disableWriteTools": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable all write operations in MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disableSearchTools": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable all search operations in MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disableBrowserTool": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable the built-in browser tool (useful when using Antigravity's native browser)"
|
||||
},
|
||||
"hanzo.mcp.enabledTools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "List of explicitly enabled MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disabledTools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "List of explicitly disabled MCP tools"
|
||||
},
|
||||
"hanzo.api.endpoint": {
|
||||
"type": "string",
|
||||
"default": "https://api.hanzo.ai/ext/v1",
|
||||
"description": "API endpoint for Hanzo services"
|
||||
},
|
||||
"hanzo.debug": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable debug logging"
|
||||
},
|
||||
"hanzo.llm.provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"hanzo",
|
||||
"lmstudio",
|
||||
"ollama",
|
||||
"openai",
|
||||
"anthropic"
|
||||
],
|
||||
"default": "hanzo",
|
||||
"description": "LLM provider to use for AI features"
|
||||
},
|
||||
"hanzo.llm.hanzo.apiKey": {
|
||||
"type": "string",
|
||||
"description": "API key for Hanzo AI Gateway"
|
||||
},
|
||||
"hanzo.llm.lmstudio.endpoint": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:1234/v1",
|
||||
"description": "LM Studio API endpoint"
|
||||
},
|
||||
"hanzo.llm.lmstudio.model": {
|
||||
"type": "string",
|
||||
"description": "Model to use in LM Studio"
|
||||
},
|
||||
"hanzo.llm.ollama.endpoint": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:11434",
|
||||
"description": "Ollama API endpoint"
|
||||
},
|
||||
"hanzo.llm.ollama.model": {
|
||||
"type": "string",
|
||||
"default": "llama2",
|
||||
"description": "Model to use in Ollama"
|
||||
},
|
||||
"hanzo.llm.openai.apiKey": {
|
||||
"type": "string",
|
||||
"description": "OpenAI API key"
|
||||
},
|
||||
"hanzo.llm.openai.model": {
|
||||
"type": "string",
|
||||
"default": "gpt-4",
|
||||
"description": "OpenAI model to use"
|
||||
},
|
||||
"hanzo.llm.anthropic.apiKey": {
|
||||
"type": "string",
|
||||
"description": "Anthropic API key"
|
||||
},
|
||||
"hanzo.llm.anthropic.model": {
|
||||
"type": "string",
|
||||
"default": "claude-3-opus-20240229",
|
||||
"description": "Anthropic model to use"
|
||||
}
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "hanzo.reanalyzeProject",
|
||||
"group": "navigation",
|
||||
"when": "resourceScheme != extension"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "node scripts/compile-main.js || true && npm run build:mcp",
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"pretest": "npm run compile && npm run lint",
|
||||
"lint": "eslint .",
|
||||
"test": "node ./out/test/runTest.js",
|
||||
"test:simple": "node test-simple.js",
|
||||
"test:pm": "cross-env MOCHA_TEST_FILE=\"ProjectManager Test Suite\" node ./out/test/runTest.js",
|
||||
"test:auth": "cross-env MOCHA_TEST_FILE=\"Auth and API Test Suite\" node ./out/test/runTest.js",
|
||||
"test:file-collection": "cross-env MOCHA_TEST_FILE=\"FileCollectionService Test Suite\" node ./out/test/runTest.js",
|
||||
"test:mcp": "cross-env MOCHA_TEST_FILE=\"MCP.*Tool Test Suite\" node ./out/test/runTest.js",
|
||||
"test:mcp-proxy": "cross-env MOCHA_TEST_FILE=\"MCP.*Proxy Test\" node ./out/test/runTest.js",
|
||||
"test:mcp-integration": "cross-env MOCHA_TEST_FILE=\"MCP Proxy Integration\" node ./out/test/runTest.js",
|
||||
"test:coverage": "c8 npm test",
|
||||
"test:vitest": "vitest",
|
||||
"test:vitest:coverage": "vitest --coverage",
|
||||
"test:vitest:run": "vitest run --coverage",
|
||||
"build:mcp": "node scripts/build-mcp-standalone.js",
|
||||
"build:claude-desktop": "npm run build:mcp && node scripts/build-claude-desktop.js",
|
||||
"build": "tsc -p ./",
|
||||
"build:dxt": "node scripts/build-dxt.js",
|
||||
"build:npm": "node scripts/build-mcp-npm.js",
|
||||
"build:cursor": "node scripts/build-cursor.js",
|
||||
"build:windsurf": "node scripts/build-windsurf.js",
|
||||
"build:all": "node scripts/build-all-platforms.js",
|
||||
"build:browser-extension": "node src/browser-extension/build.js",
|
||||
"package": "npm run build:all && sed -i '' 's/\"name\": \"@hanzo\\/extension\"/\"name\": \"hanzo-extension\"/' package.json && vsce package --no-dependencies; sed -i '' 's/\"name\": \"hanzo-extension\"/\"name\": \"@hanzo\\/extension\"/' package.json",
|
||||
"package:claude": "npm run build:claude-desktop",
|
||||
"package:dxt": "npm run build:dxt",
|
||||
"publish": "npm run package && npx vsce publish && ovsx publish",
|
||||
"publish:npm": "npm run build:claude-desktop && cd dist/claude-desktop && npm publish",
|
||||
"dev": "cross-env VSCODE_ENV=development npm run watch",
|
||||
"dev:mcp": "cross-env MCP_TRANSPORT=stdio node ./out/mcp-server-standalone.js",
|
||||
"start:prod": "cross-env VSCODE_ENV=production npm run watch",
|
||||
"preview": "npx serve . -p 3000",
|
||||
"deploy": "vercel --prod",
|
||||
"deploy:preview": "vercel"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/inquirer": "^9.0.8",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/sinonjs__fake-timers": "^8.1.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"@vscode/test-cli": "^0.0.11",
|
||||
"@vscode/test-electron": "^2.5.2",
|
||||
"@vscode/vsce": "^2.24.0",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"c8": "^10.1.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.25.6",
|
||||
"eslint": "^8.26.0",
|
||||
"glob": "^10.3.10",
|
||||
"jsdom": "^26.1.0",
|
||||
"mocha": "^11.0.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"sinon": "^21.0.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lancedb/lancedb": "^0.21.0",
|
||||
"@modelcontextprotocol/sdk": "^1.14.0",
|
||||
"@supabase/supabase-js": "^2.48.1",
|
||||
"@typescript-eslint/typescript-estree": "^8.35.1",
|
||||
"axios": "^1.6.2",
|
||||
"ignore": "^7.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimatch": "^10.0.1",
|
||||
"rxdb": "^16.15.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-javascript": "^0.23.1",
|
||||
"tree-sitter-typescript": "^0.23.2",
|
||||
"zod": "^3.25.71"
|
||||
},
|
||||
"__metadata": {
|
||||
"id": "fd0ca99f-75f0-4318-af8c-660c5e883c16",
|
||||
"publisherId": "c3a25647-333f-4954-a3a7-a5487b656f72",
|
||||
"publisherDisplayName": "hanzo-ai",
|
||||
"targetPlatform": "undefined",
|
||||
"isApplicationScoped": false,
|
||||
"isPreReleaseVersion": false,
|
||||
"hasPreReleaseVersion": false,
|
||||
"installedTimestamp": 1743812550788,
|
||||
"pinned": false,
|
||||
"preRelease": false,
|
||||
"source": "gallery",
|
||||
"size": 15245886
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ interface AuthToken {
|
||||
}
|
||||
|
||||
interface CasdoorConfig {
|
||||
endpoint: string;
|
||||
endpoint: string; // Casdoor API (iam.hanzo.ai)
|
||||
loginUrl: string; // Login UI (hanzo.id)
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
applicationName: string;
|
||||
@@ -38,9 +39,10 @@ export class StandaloneAuthManager {
|
||||
this.tokenFile = path.join(this.configDir, 'auth.json');
|
||||
this.deviceIdFile = path.join(this.configDir, 'device.json');
|
||||
|
||||
// Casdoor configuration for iam.hanzo.ai
|
||||
// Casdoor configuration: login UI on hanzo.id, API on iam.hanzo.ai
|
||||
this.casdoorConfig = {
|
||||
endpoint: process.env.HANZO_IAM_ENDPOINT || 'https://iam.hanzo.ai',
|
||||
loginUrl: process.env.HANZO_IAM_LOGIN_URL || 'https://hanzo.id',
|
||||
clientId: process.env.HANZO_IAM_CLIENT_ID || 'hanzo-mcp',
|
||||
clientSecret: process.env.HANZO_IAM_CLIENT_SECRET,
|
||||
applicationName: 'hanzo-mcp',
|
||||
@@ -102,7 +104,7 @@ export class StandaloneAuthManager {
|
||||
|
||||
private async refreshToken(refreshToken: string): Promise<AuthToken | null> {
|
||||
try {
|
||||
const response = await axios.post(`${this.casdoorConfig.endpoint}/api/refresh-token`, {
|
||||
const response = await axios.post(`${this.casdoorConfig.endpoint}/api/login/oauth/access_token`, {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: this.casdoorConfig.clientId,
|
||||
@@ -160,50 +162,44 @@ export class StandaloneAuthManager {
|
||||
const deviceId = this.getDeviceId();
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Build OAuth URL for Casdoor
|
||||
const authUrl = new URL(`${this.casdoorConfig.endpoint}/login/oauth/authorize`);
|
||||
// Build OAuth URL — Authorization Code + PKCE (OAuth 2.1 standard)
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
|
||||
const authUrl = new URL(`${this.casdoorConfig.loginUrl}/login/oauth/authorize`);
|
||||
authUrl.searchParams.append('client_id', this.casdoorConfig.clientId);
|
||||
authUrl.searchParams.append('response_type', 'code');
|
||||
authUrl.searchParams.append('redirect_uri', 'http://localhost:8765/callback');
|
||||
authUrl.searchParams.append('scope', 'read write');
|
||||
authUrl.searchParams.append('state', state);
|
||||
authUrl.searchParams.append('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.append('code_challenge_method', 'S256');
|
||||
authUrl.searchParams.append('device_id', deviceId);
|
||||
|
||||
// Start local callback server
|
||||
const callbackServer = await this.startCallbackServer(state);
|
||||
|
||||
const callbackResult = await this.startCallbackServer(state, codeVerifier);
|
||||
|
||||
// Open browser
|
||||
console.log('Opening browser for authentication...');
|
||||
this.openBrowser(authUrl.toString());
|
||||
|
||||
// Wait for callback
|
||||
const authCode = await callbackServer;
|
||||
|
||||
if (!authCode) {
|
||||
console.error('Authentication failed: No authorization code received');
|
||||
const result = await callbackResult;
|
||||
|
||||
if (!result) {
|
||||
console.error('Authentication failed: No token received');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
const tokenResponse = await axios.post(`${this.casdoorConfig.endpoint}/api/login/oauth/access_token`, {
|
||||
grant_type: 'authorization_code',
|
||||
code: authCode,
|
||||
client_id: this.casdoorConfig.clientId,
|
||||
client_secret: this.casdoorConfig.clientSecret,
|
||||
redirect_uri: 'http://localhost:8765/callback'
|
||||
});
|
||||
const token: AuthToken = {
|
||||
token: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
expiresAt: result.expiresAt || Date.now() + 168 * 3600 * 1000,
|
||||
};
|
||||
|
||||
if (tokenResponse.data?.access_token) {
|
||||
const token: AuthToken = {
|
||||
token: tokenResponse.data.access_token,
|
||||
refreshToken: tokenResponse.data.refresh_token,
|
||||
expiresAt: Date.now() + (tokenResponse.data.expires_in || 3600) * 1000
|
||||
};
|
||||
|
||||
await this.storeToken(token);
|
||||
console.log('\n✅ Authentication successful!\n');
|
||||
return true;
|
||||
}
|
||||
await this.storeToken(token);
|
||||
console.log('\n✅ Authentication successful!\n');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Authentication failed:', error);
|
||||
}
|
||||
@@ -211,54 +207,85 @@ export class StandaloneAuthManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
private async startCallbackServer(expectedState: string): Promise<string | null> {
|
||||
private async startCallbackServer(expectedState: string, codeVerifier?: string): Promise<{ accessToken: string; refreshToken?: string; expiresAt?: number } | null> {
|
||||
const endpoint = this.casdoorConfig.endpoint;
|
||||
const clientId = this.casdoorConfig.clientId;
|
||||
const clientSecret = this.casdoorConfig.clientSecret;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const http = require('http');
|
||||
const url = require('url');
|
||||
|
||||
const server = http.createServer((req: any, res: any) => {
|
||||
|
||||
const successHtml = `<html><body style="font-family:system-ui;text-align:center;padding:50px">
|
||||
<h1 style="color:#10b981">Authentication Successful!</h1>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(()=>window.close(),3000)</script></body></html>`;
|
||||
|
||||
const server = http.createServer(async (req: any, res: any) => {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
|
||||
if (parsedUrl.pathname === '/callback') {
|
||||
const code = parsedUrl.query.code;
|
||||
const state = parsedUrl.query.state;
|
||||
|
||||
if (state === expectedState && code) {
|
||||
if (parsedUrl.pathname !== '/callback') return;
|
||||
|
||||
const state = parsedUrl.query.state;
|
||||
if (state !== expectedState) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Authentication Failed</h1><p>Invalid state.</p>');
|
||||
server.close();
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = parsedUrl.query.code;
|
||||
const directToken = parsedUrl.query.access_token;
|
||||
|
||||
if (code && codeVerifier) {
|
||||
// Authorization Code + PKCE exchange
|
||||
try {
|
||||
const body: Record<string, string> = {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
code: code as string,
|
||||
redirect_uri: 'http://localhost:8765/callback',
|
||||
code_verifier: codeVerifier,
|
||||
};
|
||||
if (clientSecret) body.client_secret = clientSecret;
|
||||
|
||||
const tokenResp = await axios.post(`${endpoint}/api/login/oauth/access_token`, body);
|
||||
const tokens = tokenResp.data;
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui; text-align: center; padding: 50px; }
|
||||
h1 { color: #10b981; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>✅ Authentication Successful!</h1>
|
||||
<p>You can now close this window and return to your terminal.</p>
|
||||
<script>window.setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
res.end(successHtml);
|
||||
server.close();
|
||||
resolve(code as string);
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Authentication Failed</h1><p>Invalid state or missing code.</p>');
|
||||
resolve({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/html' });
|
||||
res.end(`<h1>Token Exchange Failed</h1><p>${err.message}</p>`);
|
||||
server.close();
|
||||
resolve(null);
|
||||
}
|
||||
} else if (directToken) {
|
||||
// Implicit flow fallback
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(successHtml);
|
||||
server.close();
|
||||
const expiresIn = parsedUrl.query.expires_in ? parseInt(parsedUrl.query.expires_in, 10) : undefined;
|
||||
resolve({
|
||||
accessToken: directToken as string,
|
||||
refreshToken: parsedUrl.query.refresh_token as string | undefined,
|
||||
expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,
|
||||
});
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Authentication Failed</h1><p>No code or token received.</p>');
|
||||
server.close();
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
server.listen(8765, 'localhost');
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
resolve(null);
|
||||
}, 300000);
|
||||
setTimeout(() => { server.close(); resolve(null); }, 300000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export class BrowserExtensionServer extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.wss.close();
|
||||
public close(callback?: () => void) {
|
||||
this.wss.close(callback);
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,12 @@ importers:
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
@@ -4779,6 +4785,11 @@ packages:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-dom@18.3.1:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
@@ -4937,6 +4948,9 @@ packages:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
semver@5.7.2:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
hasBin: true
|
||||
@@ -10494,6 +10508,12 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
react-dom@18.3.1(react@18.3.1):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react@18.3.1:
|
||||
@@ -10716,6 +10736,10 @@ snapshots:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
semver@5.7.2: {}
|
||||
|
||||
semver@7.7.2: {}
|
||||
|
||||