Skip to content

Commit adeb16a

Browse files
yethikrishnaclaude
andcommitted
feat: make CLI/SDK work standalone without backend
Enable LevelCode to run fully standalone without requiring the LevelCode backend server. Users provide their own API keys directly. SDK changes: - Make standalone mode default when NEXT_PUBLIC_LEVELCODE_APP_URL not set - Add direct OpenRouter model support via OPENROUTER_API_KEY env var - Add direct Anthropic model support via ANTHROPIC_API_KEY env var - All database functions gracefully no-op in standalone mode - Export isStandaloneMode for CLI consumption CLI changes: - Remove referral system (not available in open-source mode) - Remove credits/billing tracking (unlimited in standalone) - Remove out-of-credits banner (never shown in standalone) - Make ads gracefully degrade when backend unavailable - Simplify auth to accept direct API keys without web login - Skip backend validation in standalone mode - Make agent publishing unavailable in standalone mode - Make PostHog analytics no-op when keys not configured - Always report connected status in standalone mode 39 files changed, -874 lines net reduction. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 06cd4ce commit adeb16a

39 files changed

Lines changed: 334 additions & 1208 deletions

cli/src/chat.tsx

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ import {
1010
} from 'react'
1111
import { useShallow } from 'zustand/react/shallow'
1212

13-
import { getAdsEnabled } from './commands/ads'
1413
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
15-
import { AdBanner } from './components/ad-banner'
1614
import { BottomStatusLine } from './components/bottom-status-line'
1715
import { ChatInputBar } from './components/chat-input-bar'
1816
import { LoadPreviousButton } from './components/load-previous-button'
@@ -37,7 +35,6 @@ import { useChatUI } from './hooks/use-chat-ui'
3735
import { useClaudeQuotaQuery } from './hooks/use-claude-quota-query'
3836
import { useClipboard } from './hooks/use-clipboard'
3937
import { useEvent } from './hooks/use-event'
40-
import { useGravityAd } from './hooks/use-gravity-ad'
4138
import { useInputHistory } from './hooks/use-input-history'
4239
import { usePublishMutation } from './hooks/use-publish-mutation'
4340
import { useSendMessage } from './hooks/use-send-message'
@@ -51,7 +48,6 @@ import { useReviewStore } from './state/review-store'
5148
import { useFeedbackStore } from './state/feedback-store'
5249
import { useMessageBlockStore } from './state/message-block-store'
5350
import { usePublishStore } from './state/publish-store'
54-
import { reportActivity } from './utils/activity-tracker'
5551
import { trackEvent } from './utils/analytics'
5652
import { getClaudeOAuthStatus } from './utils/claude-oauth'
5753
import { showClipboardMessage } from './utils/clipboard'
@@ -159,7 +155,6 @@ export const Chat = ({
159155
} = useChatState()
160156

161157
const { statusMessage } = useClipboard()
162-
const { ad } = useGravityAd()
163158

164159
// Set initial mode from CLI flag on mount
165160
useEffect(() => {
@@ -206,17 +201,10 @@ export const Chat = ({
206201
// Get loaded skills for slash commands
207202
const loadedSkills = useMemo(() => getLoadedSkills(), [])
208203

209-
// Filter slash commands based on current ads state - only show the option that changes state
210-
// Also merge in skill commands
204+
// Merge in skill commands
211205
const filteredSlashCommands = useMemo(() => {
212-
const adsEnabled = getAdsEnabled()
213-
const allCommands = getSlashCommandsWithSkills(loadedSkills)
214-
return allCommands.filter((cmd) => {
215-
if (cmd.id === 'ads:enable') return !adsEnabled
216-
if (cmd.id === 'ads:disable') return adsEnabled
217-
return true
218-
})
219-
}, [inputValue, loadedSkills]) // Re-evaluate when input changes (user may have just toggled)
206+
return getSlashCommandsWithSkills(loadedSkills)
207+
}, [loadedSkills])
220208

221209
const {
222210
slashContext,
@@ -739,16 +727,6 @@ export const Chat = ({
739727
inputValueRef.current = inputValue
740728
}, [inputValue])
741729

742-
// Report activity on input changes for ad rotation (debounced via separate effect)
743-
const lastReportedActivityRef = useRef<number>(0)
744-
useEffect(() => {
745-
const now = Date.now()
746-
// Throttle to max once per second to avoid excessive calls
747-
if (now - lastReportedActivityRef.current > 1000) {
748-
lastReportedActivityRef.current = now
749-
reportActivity()
750-
}
751-
}, [inputValue])
752730
useEffect(() => {
753731
cursorPositionRef.current = cursorPosition
754732
}, [cursorPosition])
@@ -842,8 +820,6 @@ export const Chat = ({
842820
}, [feedbackMode, askUserState, inputRef])
843821

844822
const handleSubmit = useCallback(async () => {
845-
// Report activity for ad rotation
846-
reportActivity()
847823
// Update terminal title with truncated user input
848824
if (inputValue.trim()) {
849825
setTerminalTitle(inputValue)
@@ -1301,20 +1277,8 @@ export const Chat = ({
13011277
// Determine if Claude is actively streaming/waiting
13021278
const isClaudeActive = isStreaming || isWaitingForResponse
13031279

1304-
// Track mouse movement for ad activity (throttled)
1305-
const lastMouseActivityRef = useRef<number>(0)
1306-
const handleMouseActivity = useCallback(() => {
1307-
const now = Date.now()
1308-
// Throttle to max once per second
1309-
if (now - lastMouseActivityRef.current > 1000) {
1310-
lastMouseActivityRef.current = now
1311-
reportActivity()
1312-
}
1313-
}, [])
1314-
13151280
return (
13161281
<box
1317-
onMouseMove={handleMouseActivity}
13181282
style={{
13191283
flexDirection: 'column',
13201284
gap: 0,
@@ -1404,8 +1368,6 @@ export const Chat = ({
14041368
/>
14051369
)}
14061370

1407-
{ad && getAdsEnabled() && <AdBanner ad={ad} />}
1408-
14091371
{reviewMode ? (
14101372
<ReviewScreen
14111373
onSelectOption={handleReviewOptionSelect}

cli/src/commands/ads.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,29 @@
1-
import { useChatStore } from '../state/chat-store'
2-
import { logger } from '../utils/logger'
31
import { getSystemMessage } from '../utils/message-history'
4-
import { saveSettings, loadSettings } from '../utils/settings'
52

63
import type { ChatMessage } from '../types/chat'
74

85
export const handleAdsEnable = (): {
96
postUserMessage: (messages: ChatMessage[]) => ChatMessage[]
107
} => {
11-
logger.info('[gravity] Enabling ads')
12-
13-
saveSettings({ adsEnabled: true })
14-
158
return {
169
postUserMessage: (messages) => [
1710
...messages,
18-
getSystemMessage('Ads enabled. You will see contextual ads above the input and earn credits from impressions.'),
11+
getSystemMessage('Ads are not available in standalone mode.'),
1912
],
2013
}
2114
}
2215

2316
export const handleAdsDisable = (): {
2417
postUserMessage: (messages: ChatMessage[]) => ChatMessage[]
2518
} => {
26-
logger.info('[gravity] Disabling ads')
27-
saveSettings({ adsEnabled: false })
28-
2919
return {
3020
postUserMessage: (messages) => [
3121
...messages,
32-
getSystemMessage('Ads disabled.'),
22+
getSystemMessage('Ads are not available in standalone mode.'),
3323
],
3424
}
3525
}
3626

3727
export const getAdsEnabled = (): boolean => {
38-
// If no mode provided, get it from the store
39-
const mode = useChatStore.getState().agentMode
40-
41-
// In FREE mode, ads are always enabled regardless of saved setting
42-
if (mode === 'FREE') {
43-
return true
44-
}
45-
46-
// Otherwise, use the saved setting
47-
const settings = loadSettings()
48-
return settings.adsEnabled ?? false
28+
return false
4929
}

cli/src/commands/command-registry.ts

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import { useThemeStore } from '../hooks/use-theme'
55
import { handleHelpCommand } from './help'
66
import { handleImageCommand } from './image'
77
import { handleInitializationFlowLocally } from './init'
8-
import { handleReferralCode } from './referral'
98
import { runBashCommand } from './router'
10-
import { normalizeReferralCode } from './router-utils'
119
import { handleUsageCommand } from './usage'
1210
import { WEBSITE_URL } from '../login/constants'
1311
import { useChatStore } from '../state/chat-store'
@@ -229,38 +227,15 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
229227
clearInput(params)
230228
},
231229
}),
232-
defineCommandWithArgs({
230+
defineCommand({
233231
name: 'referral',
234232
aliases: ['redeem'],
235-
handler: async (params, args) => {
236-
const trimmedArgs = args.trim()
237-
238-
// If user provided a code directly, redeem it immediately
239-
if (trimmedArgs) {
240-
const code = normalizeReferralCode(trimmedArgs)
241-
try {
242-
const { postUserMessage } = await handleReferralCode(code)
243-
params.setMessages((prev) => [
244-
...prev,
245-
getUserMessage(params.inputValue.trim()),
246-
...postUserMessage([]),
247-
])
248-
} catch (error) {
249-
const errorMessage =
250-
error instanceof Error ? error.message : 'Unknown error'
251-
params.setMessages((prev) => [
252-
...prev,
253-
getUserMessage(params.inputValue.trim()),
254-
getSystemMessage(`Error redeeming referral code: ${errorMessage}`),
255-
])
256-
}
257-
params.saveToHistory(params.inputValue.trim())
258-
clearInput(params)
259-
return
260-
}
261-
262-
// Otherwise enter referral mode
263-
useChatStore.getState().setInputMode('referral')
233+
handler: (params) => {
234+
params.setMessages((prev) => [
235+
...prev,
236+
getUserMessage(params.inputValue.trim()),
237+
getSystemMessage('The referral system is not available in open-source mode.'),
238+
])
264239
params.saveToHistory(params.inputValue.trim())
265240
clearInput(params)
266241
},

cli/src/commands/publish.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { WEBSITE_URL } from '@levelcode/sdk'
1+
import { WEBSITE_URL, isStandaloneMode } from '@levelcode/sdk'
22

33
import { getUserCredentials } from '../utils/auth'
44
import { getApiClient, setApiClientAuthToken } from '../utils/levelcode-api'
@@ -93,6 +93,15 @@ async function publishAgentTemplates(
9393
* @returns PublishResult with success/error information
9494
*/
9595
export async function handlePublish(agentIds: string[]): Promise<PublishResult> {
96+
if (isStandaloneMode()) {
97+
return {
98+
success: false,
99+
error:
100+
'Agent publishing is not available in open-source standalone mode.',
101+
hint: 'Visit levelcode.ai for the hosted version with agent publishing support.',
102+
}
103+
}
104+
96105
const user = getUserCredentials()
97106

98107
if (!user) {

cli/src/commands/referral.ts

Lines changed: 8 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,15 @@
1-
import { env } from '@levelcode/common/env'
2-
import { CREDITS_REFERRAL_BONUS } from '@levelcode/common/old-constants'
3-
4-
import { getAuthToken } from '../utils/auth'
5-
import { getApiClient, setApiClientAuthToken } from '../utils/levelcode-api'
6-
import { logger } from '../utils/logger'
71
import { getSystemMessage } from '../utils/message-history'
82

93
import type { PostUserMessageFn } from '../types/contracts/send-message'
104

11-
export async function handleReferralCode(referralCode: string): Promise<{
5+
export async function handleReferralCode(_referralCode: string): Promise<{
126
postUserMessage: PostUserMessageFn
137
}> {
14-
const authToken = getAuthToken()
15-
16-
if (!authToken) {
17-
const postUserMessage: PostUserMessageFn = (prev) => [
18-
...prev,
19-
getSystemMessage(
20-
'Please log in first to redeem a referral code. Use /login to authenticate.',
21-
),
22-
]
23-
return { postUserMessage }
24-
}
25-
26-
setApiClientAuthToken(authToken)
27-
const apiClient = getApiClient()
28-
29-
try {
30-
const response = await apiClient.referral({ referralCode })
31-
32-
if (!response.ok) {
33-
const errorMessage = response.error ?? 'Failed to redeem referral code'
34-
logger.error(
35-
{
36-
referralCode,
37-
error: errorMessage,
38-
},
39-
'Error redeeming referral code',
40-
)
41-
const postUserMessage: PostUserMessageFn = (prev) => [
42-
...prev,
43-
getSystemMessage(`Error: ${errorMessage}`),
44-
]
45-
return { postUserMessage }
46-
}
47-
48-
const creditsRedeemed =
49-
response.data?.credits_redeemed ?? CREDITS_REFERRAL_BONUS
50-
const postUserMessage: PostUserMessageFn = (prev) => [
51-
...prev,
52-
getSystemMessage(
53-
`🎉 Noice, you've earned an extra ${creditsRedeemed} credits!\n\n` +
54-
`(pssst: you can also refer new users and earn ${CREDITS_REFERRAL_BONUS} credits for each referral at: ${env.NEXT_PUBLIC_LEVELCODE_APP_URL}/referrals)`,
55-
),
56-
]
57-
return { postUserMessage }
58-
} catch (error) {
59-
const errorMessage = error instanceof Error ? error.message : String(error)
60-
logger.error(
61-
{
62-
referralCode,
63-
error: errorMessage,
64-
},
65-
'Error redeeming referral code',
66-
)
67-
const postUserMessage: PostUserMessageFn = (prev) => [
68-
...prev,
69-
getSystemMessage(`Error redeeming referral code: ${errorMessage}`),
70-
]
71-
return { postUserMessage }
72-
}
8+
const postUserMessage: PostUserMessageFn = (prev) => [
9+
...prev,
10+
getSystemMessage(
11+
'The referral system is not available in open-source mode.',
12+
),
13+
]
14+
return { postUserMessage }
7315
}

cli/src/commands/router-utils.ts

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ import { SLASHLESS_COMMAND_IDS } from '../data/slash-commands'
22

33
/**
44
* Normalize user input by stripping the leading slash if present.
5-
* This is used for referral codes which work with or without the slash.
65
*
76
* @example
87
* normalizeInput('/help') // => 'help'
98
* normalizeInput('help') // => 'help'
10-
* normalizeInput('/ref-abc123') // => 'ref-abc123'
119
*/
1210
export function normalizeInput(input: string): string {
1311
return input.startsWith('/') ? input.slice(1) : input
@@ -19,7 +17,6 @@ export function normalizeInput(input: string): string {
1917
* @example
2018
* isSlashCommand('/help') // => true
2119
* isSlashCommand('help') // => false
22-
* isSlashCommand('/ref-abc123') // => true
2320
*/
2421
export function isSlashCommand(input: string): boolean {
2522
return input.trim().startsWith('/')
@@ -47,54 +44,6 @@ export function parseCommand(input: string): string {
4744
return firstWord.toLowerCase()
4845
}
4946

50-
/**
51-
* Check if the input is a referral code (starts with 'ref-').
52-
* Works with or without the leading slash.
53-
*
54-
* @example
55-
* isReferralCode('ref-abc123') // => true
56-
* isReferralCode('/ref-abc123') // => true
57-
* isReferralCode('reference') // => false
58-
*/
59-
export function isReferralCode(input: string): boolean {
60-
const normalized = normalizeInput(input.trim())
61-
return normalized.startsWith('ref-')
62-
}
63-
64-
/**
65-
* Extract the referral code from user input.
66-
* Returns the normalized code without the leading slash.
67-
*
68-
* @example
69-
* extractReferralCode('/ref-abc123') // => 'ref-abc123'
70-
* extractReferralCode('ref-abc123') // => 'ref-abc123'
71-
*/
72-
export function extractReferralCode(input: string): string {
73-
return normalizeInput(input.trim())
74-
}
75-
76-
const REFERRAL_PREFIX = 'ref-'
77-
78-
/**
79-
* Normalize a referral code by ensuring it has the lowercase 'ref-' prefix.
80-
* Handles case-insensitive prefix detection (REF-, Ref-, etc.) and preserves
81-
* the original casing of the code portion.
82-
*
83-
* @example
84-
* normalizeReferralCode('abc123') // => 'ref-abc123'
85-
* normalizeReferralCode('ref-abc123') // => 'ref-abc123'
86-
* normalizeReferralCode('REF-ABC123') // => 'ref-ABC123'
87-
* normalizeReferralCode('Ref-XYZ') // => 'ref-XYZ'
88-
*/
89-
export function normalizeReferralCode(code: string): string {
90-
const trimmed = code.trim()
91-
const hasPrefix = trimmed.toLowerCase().startsWith(REFERRAL_PREFIX)
92-
const codeWithoutPrefix = hasPrefix
93-
? trimmed.slice(REFERRAL_PREFIX.length)
94-
: trimmed
95-
return `${REFERRAL_PREFIX}${codeWithoutPrefix}`
96-
}
97-
9847
/**
9948
* Result of parsing a command-like input.
10049
*/

0 commit comments

Comments
 (0)