Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ browser acts as the runtime host for render, lint, and typecheck flows.
- Live IDE: https://knightedcodemonkey.github.io/develop/
- Source repository: https://github.com/knightedcodemonkey/develop

## BYOT Guide

- GitHub PAT setup and usage: [docs/byot.md](docs/byot.md)

## License

MIT
67 changes: 67 additions & 0 deletions docs/byot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# BYOT Setup for GitHub in @knighted/develop

This guide explains how to create and use a fine-grained GitHub Personal Access Token (PAT) for the BYOT flow in `@knighted/develop`.

## What BYOT does in the app

When the AI/BYOT feature is enabled, the token is used to:

- authenticate GitHub API requests
- load repositories where you have write access
- let you choose which repository to work with

As additional AI/PR features roll out, the same token is also used for model and repository operations that require the configured permissions.

## Privacy and storage behavior

- Your token is stored only in your browser `localStorage`.
- The token is never sent to any service except the GitHub endpoints required by the feature.
- You can remove it at any time using the delete button in the BYOT controls.

## Enable the BYOT feature

Use one of these options:

1. Add `?feature-ai=true` to the app URL.
2. Set `localStorage` key `knighted:develop:feature:ai-assistant` to `true`.

## Create a fine-grained PAT

Create a fine-grained PAT in GitHub settings and grant the permissions below.

- Repository permissions screenshot: [docs/media/byot-repo-perms.png](docs/media/byot-repo-perms.png)
- Models permission screenshot: [docs/media/byot-model-perms.png](docs/media/byot-model-perms.png)

<img src="media/byot-repo-perms.png" alt="Repository PAT permissions" width="560" />
<img src="media/byot-model-perms.png" alt="Models PAT permission" width="560" />

### Repository permissions

- Contents: Read and write
- Pull requests: Read and write
- Metadata: Read-only (required)

### Account permissions

- Models: Read-only

### Repository access scope

Use either of these scopes depending on your needs:

- Only select repositories
- All repositories

`@knighted/develop` will only show repositories where your token has write access.

## Recommended setup flow

1. Create token with the permissions above.
2. Open `@knighted/develop` with `?feature-ai=true`.
3. Paste token into the BYOT input and click add.
4. Verify repository list loads.
5. Select your target repository.

## Screenshots

The screenshots above show the recommended repository and account permission settings.
Binary file added docs/media/byot-model-perms.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/media/byot-repo-perms.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions docs/next-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,19 @@ Focused follow-up work for `@knighted/develop`.
- Ensure the deterministic lane still exercises the same user-facing flows (render, typecheck, lint, diagnostics drawer/button states), only swapping the source of runtime artifacts.
- Suggested implementation prompt:
- "Add a deterministic E2E execution mode for `@knighted/develop` that serves pinned runtime artifacts locally (instead of live CDN fetches) and wire it into CI as a required check on every PR. Keep a separate lightweight CDN-smoke E2E check for real-network coverage. Validate with `npm run lint`, deterministic Playwright PR checks, and one CDN-smoke Playwright run."

4. **Issue #18 continuation (resume from Phase 2)**
- Continue the GitHub AI assistant rollout after completed Phases 0-1:
- Phase 0 complete: feature flag + scaffolding.
- Phase 1 complete: BYOT token flow, localStorage persistence, writable repo discovery/filtering.
- Implement the next slice first:
- Phase 2: chat drawer UX with streaming responses first, plus non-streaming fallback.
- Add selected repository state plumbing now so Phase 4 (PR write flow) can reuse it.
- Add README documentation for fine-grained PAT setup (reuse existing screenshots referenced in docs/byot.md).
- Keep behavior and constraints aligned with current implementation:
- Keep everything behind the existing browser-only AI feature flag.
- Preserve BYOT token semantics (localStorage persistence until user deletes).
- Keep CDN-first runtime behavior and existing fallback model.
- Do not add dependencies without explicit approval.
- Suggested implementation prompt:
- "Continue Issue #18 in @knighted/develop from the current Phase 1 baseline. Implement Phase 2 by adding a separate AI chat drawer with streaming response rendering (primary) and a non-streaming fallback path. Wire selected repository state as shared app state for upcoming Phase 4 PR actions. Update README with a concise fine-grained PAT setup section that links to existing BYOT screenshot assets/docs. Keep all AI/BYOT UI and behavior behind the existing browser-only feature flag, preserve current token persistence and repo filtering behavior, and validate with npm run lint plus targeted Playwright coverage for chat drawer visibility, streaming/fallback behavior, and repo-context selection plumbing."
74 changes: 71 additions & 3 deletions playwright/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import type { Page } from '@playwright/test'
const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev'
const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html'

const waitForInitialRender = async (page: Page) => {
await page.goto(appEntryPath)
const waitForAppReady = async (page: Page, path = appEntryPath) => {
await page.goto(path)
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
await expect(page.locator('#status')).toHaveText('Rendered')
await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '')
}

const waitForInitialRender = async (page: Page) => {
await waitForAppReady(page)
await expect(page.locator('#status')).toHaveText('Rendered')
}

const expectPreviewHasRenderedContent = async (page: Page) => {
const previewHost = page.locator('#preview-host')
await expect(previewHost.locator('pre')).toHaveCount(0)
Expand Down Expand Up @@ -126,6 +130,70 @@ const expectCollapseButtonState = async (
}
}

test('BYOT controls stay hidden when feature flag is disabled', async ({ page }) => {
await waitForAppReady(page)

const byotControls = page.locator('#github-ai-controls')
await expect(byotControls).toHaveAttribute('hidden', '')
await expect(byotControls).toBeHidden()
})

test('BYOT controls render when feature flag is enabled by query param', async ({
page,
}) => {
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)

const byotControls = page.locator('#github-ai-controls')
await expect(byotControls).toBeVisible()
await expect(page.locator('#github-token-input')).toBeVisible()
await expect(page.locator('#github-token-add')).toBeVisible()
})

test('BYOT remembers selected repository across reloads', async ({ page }) => {
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 2,
owner: { login: 'knightedcodemonkey' },
name: 'develop',
full_name: 'knightedcodemonkey/develop',
default_branch: 'main',
permissions: { push: true },
},
{
id: 1,
owner: { login: 'knightedcodemonkey' },
name: 'css',
full_name: 'knightedcodemonkey/css',
default_branch: 'main',
permissions: { push: true },
},
]),
})
})

await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)

await page.locator('#github-token-input').fill('github_pat_fake_1234567890')
await page.locator('#github-token-add').click()

const repoSelect = page.locator('#github-repo-select')
await expect(repoSelect).toBeEnabled()
await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories')

await repoSelect.selectOption('knightedcodemonkey/develop')
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')

await page.reload()
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
await expect(page.locator('#github-token-add')).toBeHidden()
await expect(page.locator('#github-token-delete')).toBeVisible()
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
})

test('renders default playground preview', async ({ page }) => {
await waitForInitialRender(page)

Expand Down
24 changes: 24 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
import { createCodeMirrorEditor } from './modules/editor-codemirror.js'
import { defaultCss, defaultJsx, defaultReactJsx } from './modules/defaults.js'
import { createDiagnosticsUiController } from './modules/diagnostics-ui.js'
import { isAiAssistantFeatureEnabled } from './modules/feature-flags.js'
import { createGitHubByotControls } from './modules/github-byot-controls.js'
import { createLayoutThemeController } from './modules/layout-theme.js'
import { createLintDiagnosticsController } from './modules/lint-diagnostics.js'
import { createPreviewBackgroundController } from './modules/preview-background.js'
Expand All @@ -15,6 +17,13 @@ import { createTypeDiagnosticsController } from './modules/type-diagnostics.js'

const statusNode = document.getElementById('status')
const appGrid = document.querySelector('.app-grid')
const githubAiControls = document.getElementById('github-ai-controls')
const githubTokenInput = document.getElementById('github-token-input')
const githubTokenInfo = document.getElementById('github-token-info')
const githubTokenAdd = document.getElementById('github-token-add')
const githubTokenDelete = document.getElementById('github-token-delete')
const githubRepoWrap = document.getElementById('github-repo-wrap')
const githubRepoSelect = document.getElementById('github-repo-select')
const appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]')
const appThemeButtons = document.querySelectorAll('[data-app-theme]')
const editorToolsButtons = document.querySelectorAll('[data-editor-tools-toggle]')
Expand Down Expand Up @@ -384,6 +393,21 @@ const {
updateUiIssueIndicators,
} = diagnosticsUi

const aiAssistantFeatureEnabled = isAiAssistantFeatureEnabled()

createGitHubByotControls({
featureEnabled: aiAssistantFeatureEnabled,
controlsRoot: githubAiControls,
tokenInput: githubTokenInput,
tokenInfoButton: githubTokenInfo,
tokenAddButton: githubTokenAdd,
tokenDeleteButton: githubTokenDelete,
repoSelect: githubRepoSelect,
repoWrap: githubRepoWrap,
onRepositoryChange: () => {},
setStatus,
})

const getStyleEditorLanguage = mode => {
if (mode === 'less') return 'less'
if (mode === 'sass') return 'sass'
Expand Down
72 changes: 72 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,78 @@ <h1>

<main class="app-grid">
<div class="app-grid-layout-controls" role="group" aria-label="App grid layout">
<div
class="app-grid-ai-controls"
id="github-ai-controls"
role="group"
aria-label="GitHub AI controls"
hidden
>
<div class="github-token-control-wrap">
<button
class="hint-icon shadow-hint github-token-info"
id="github-token-info"
type="button"
aria-label="About GitHub token"
aria-describedby="github-token-privacy-note"
data-tooltip="This token is stored only in your browser and is sent only to GitHub APIs you invoke."
>
i
</button>
<label class="sr-only" for="github-token-input">GitHub token</label>
<input
class="github-token-input"
id="github-token-input"
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
placeholder="GitHub BYOT"
aria-label="GitHub token"
aria-describedby="github-token-privacy-note"
/>
<span class="sr-only" id="github-token-privacy-note"
>This token is stored only in your browser and is sent only to GitHub APIs
you invoke.</span
>
<button
class="layout-toggle github-token-add"
id="github-token-add"
type="button"
aria-label="Add GitHub token"
title="Add GitHub token"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
</button>
<button
class="layout-toggle github-token-delete"
id="github-token-delete"
type="button"
aria-label="Delete GitHub token"
title="Delete GitHub token"
hidden
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
<path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"></path>
<path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"></path>
</svg>
</button>
</div>

<div class="github-repo-wrap" id="github-repo-wrap" hidden>
<label class="sr-only" for="github-repo-select">Repository</label>
<select id="github-repo-select" aria-label="Repository" disabled>
<option selected>Connect a token to load repositories</option>
</select>
</div>
</div>

<div class="app-grid-diagnostics-controls" role="group" aria-label="Diagnostics">
<button
class="diagnostics-toggle"
Expand Down
64 changes: 64 additions & 0 deletions src/modules/feature-flags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const aiFeatureStorageKey = 'knighted:develop:feature:ai-assistant'
const aiFeatureQueryKey = 'feature-ai'

const parseBooleanLikeValue = value => {
if (typeof value !== 'string') {
return null
}

const normalized = value.trim().toLowerCase()

if (['1', 'true', 'on', 'yes', 'enabled'].includes(normalized)) {
return true
}

if (['0', 'false', 'off', 'no', 'disabled'].includes(normalized)) {
return false
}

return null
}

const readBooleanFromLocalStorage = key => {
try {
const storedValue = localStorage.getItem(key)
return parseBooleanLikeValue(storedValue)
} catch {
return null
}
}

const readBooleanFromQueryParam = key => {
if (typeof window === 'undefined') {
return null
}

const params = new URLSearchParams(window.location.search)
if (!params.has(key)) {
return null
}

return parseBooleanLikeValue(params.get(key))
}

export const isAiAssistantFeatureEnabled = () => {
const queryValue = readBooleanFromQueryParam(aiFeatureQueryKey)
if (queryValue !== null) {
return queryValue
}

const localStorageValue = readBooleanFromLocalStorage(aiFeatureStorageKey)
if (localStorageValue !== null) {
return localStorageValue
}

return false
}

export const setAiAssistantFeatureEnabled = isEnabled => {
try {
localStorage.setItem(aiFeatureStorageKey, isEnabled ? 'true' : 'false')
} catch {
/* noop */
}
}
Loading