Skip to content
Open
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ EXPO_PUBLIC_CEREBRAS_API_KEY=csk-REPLACE_ME
# Optional tools:
# EXPO_PUBLIC_EXA_API_KEY= # web_search, else the model invents data
# EXPO_PUBLIC_UNSPLASH_ACCESS_KEY= # real photos, else LoremFlickr
# EXPO_PUBLIC_FIRECRAWL_API_KEY= # local dev only; public/bundled, never a shared key

# Optional model overrides (any OpenAI-compatible endpoint):
# EXPO_PUBLIC_CEREBRAS_BASE_URL=https://api.cerebras.ai/v1
Expand Down
31 changes: 27 additions & 4 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,41 @@
import { StatusBar } from "expo-status-bar";
import {
Inter_300Light,
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
Inter_800ExtraBold,
useFonts,
} from "@expo-google-fonts/inter";
import React, { useEffect } from "react";
import { SafeAreaProvider } from "react-native-safe-area-context";
import GenOS from "./src/genos/GenOS";
import { initTelemetry } from "./src/genos/telemetry";
import { TypographyProvider } from "./src/genos/typography";

export default function App() {
const [fontsLoaded, fontError] = useFonts({
Inter_300Light,
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
Inter_800ExtraBold,
});

useEffect(() => {
initTelemetry();
}, []);

if (!fontsLoaded && !fontError) return null;

return (
<SafeAreaProvider>
<StatusBar style="auto" />
<GenOS />
</SafeAreaProvider>
<TypographyProvider loaded={fontsLoaded}>
<SafeAreaProvider>
<StatusBar style="auto" />
<GenOS />
</SafeAreaProvider>
</TypographyProvider>
);
}
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,18 @@ To skip the key prompt during development, or to turn on optional tools, drop a
EXPO_PUBLIC_CEREBRAS_API_KEY=csk-... # skip the first-launch key screen
# EXPO_PUBLIC_EXA_API_KEY= # enable the web_search tool (Exa)
# EXPO_PUBLIC_UNSPLASH_ACCESS_KEY= # real semantic photos (else LoremFlickr)
# EXPO_PUBLIC_FIRECRAWL_API_KEY= # local dev only; bundled into the app
# EXPO_PUBLIC_GENOS_MODEL=gemma-4-31b
# EXPO_PUBLIC_CEREBRAS_BASE_URL= # any OpenAI-compatible endpoint
```

Firecrawl is bring-your-own-key and optional. When a trusted Firecrawl workflow
needs it, AppLess asks for that provider's key and stores it separately on the
device (Keychain / Keystore, or localStorage on web). Missing or rejected
Firecrawl credentials do not affect ordinary AppLess, Cerebras generation, or
Exa search. Never ship a shared production credential through an
`EXPO_PUBLIC_*` variable: Expo inlines public variables into the client bundle.

> Expo SDK 54 is pinned to match the Expo Go build on the App Store and Play
> Store. If your Expo Go reports a different SDK, run
> `npx expo install expo@^<version> && npx expo install --fix`.
Expand Down Expand Up @@ -105,6 +113,62 @@ places), the model calls a `web_search` tool ([Exa](https://exa.ai)) mid-stream
and composes the screen from the results, sources cited. Images are referenced
as semantic queries the app resolves on-device.

The optional Firecrawl runtime adds bounded v2 REST primitives for search,
single-page scrape, and asynchronous Agent jobs. Agent runs always use a
workflow-owned schema, an explicit central credit ceiling, a depth-specific
timeout, status polling, and cooperative cancellation. Firecrawl can consume
paid credits, and cancellation may accrue a small amount while an in-flight
step reaches a stopping point. Results are capped before returning to the model
while source URLs and recoverable Agent job IDs are retained.

### Firecrawl slash commands

Type `/` in the home ask field to open the Firecrawl command menu. Partial text
such as `/fir` or `/lead` filters the menu only; execution requires an exact
slash ID. A command can include an initial argument, for example
`/firecrawl https://example.com` or
`/firecrawl-lead-research Small public company`. Unknown slash commands are
never sent to the generic model route.

The enabled catalog is:

- `/firecrawl`
- `/firecrawl-company-directories`
- `/firecrawl-competitive-intel`
- `/firecrawl-dashboard-reporting`
- `/firecrawl-deep-research`
- `/firecrawl-demo-walkthrough`
- `/firecrawl-knowledge-base`
- `/firecrawl-knowledge-ingest`
- `/firecrawl-lead-gen`
- `/firecrawl-lead-research`
- `/firecrawl-market-research`
- `/firecrawl-qa`
- `/firecrawl-research-papers`
- `/firecrawl-seo-audit`
- `/firecrawl-shop`
- `/firecrawl-website-design-clone`
- `/firecrawl-workflows`

Every command is BYOK-gated and opens a deterministic setup form before any
Firecrawl request. The form validates required text, URL, bounded number,
selection, and confirmation inputs locally. It shows the central maximum credit
budget for the selected workflow/depth and requires explicit confirmation;
Agent tasks also enforce that confirmation in the runtime. Maximums are credit
ceilings, not expected spend: most workflows cap at 150 credits, competitive
and market research at 250, and deep research at 100 quick, 300 thorough, or
750 exhaustive credits. Base URL/query work still requires confirmation before
its paid scrape/search request starts.

Results are in-app progress, summary, structured table/list, data-gap, and
source-link views. AppLess does **not** currently export CSV/JSON/files, upload
source files, write to a CRM, send outreach, make purchases, or perform
destructive submissions. Authenticated dashboards work only inside an already
authorized session boundary; login walls, CAPTCHAs, robots restrictions, and
paywalls are not bypassed. Firecrawl Agent is a Research Preview API, so its
isolated protocol adapter should be rechecked against the official v2
documentation before release.

## Packages

AppLess is a thin, native shell over the OpenUI runtime. The heavy lifting is
Expand All @@ -124,7 +188,8 @@ the OpenUI stack:
| [`@react-native-community/slider`](https://github.com/callstack/react-native-slider) | Native slider input for forms. |

Screens are generated by [Cerebras](https://cloud.cerebras.ai); live search is
powered by [Exa](https://exa.ai). Both are bring-your-own-key.
powered by [Exa](https://exa.ai), with optional bounded research through
[Firecrawl](https://firecrawl.dev). Every provider is bring-your-own-key.

## Design systems

Expand Down
209 changes: 209 additions & 0 deletions __tests__/commands.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
FIRECRAWL_COMMANDS,
MAX_COMMAND_INPUT,
commandAvailability,
commandToApp,
filterSlashCommands,
parseSlashCommand,
} from "../src/genos/commands";
import { CommandMenu, moveCommandSelection } from "../src/genos/shell/CommandMenu";
import { WorkflowSetup, initialWorkflowValues } from "../src/genos/shell/WorkflowSetup";
import {
FIRECRAWL_WORKFLOWS,
FIRECRAWL_WORKFLOW_IDS,
FIRECRAWL_WORKFLOW_SETUPS,
setupCreditBudget,
validateWorkflowSetup,
type WorkflowSetupValues,
} from "../src/genos/workflows";

describe("Firecrawl command catalog", () => {
it("contains the full stable 17-command catalog with trusted runtime and setup contracts", () => {
expect(FIRECRAWL_COMMANDS).toHaveLength(17);
expect(FIRECRAWL_COMMANDS.map((command) => command.id)).toEqual([
"firecrawl",
...FIRECRAWL_WORKFLOW_IDS.filter((id) => id !== "firecrawl"),
]);
expect(new Set(FIRECRAWL_COMMANDS.map((command) => command.id)).size).toBe(17);
for (const command of FIRECRAWL_COMMANDS) {
expect(command.providerId).toBe("firecrawl");
expect(command.availability).toBe("enabled");
expect(FIRECRAWL_WORKFLOWS[command.workflowId]).toBeDefined();
expect(FIRECRAWL_WORKFLOW_SETUPS[command.workflowId]).toBeDefined();
expect(setupCreditBudget(command.workflowId, { depth: "quick" })).toBeGreaterThan(0);
}
});

it("gates every runnable command on BYOK status", () => {
for (const command of FIRECRAWL_COMMANDS) {
expect(commandAvailability(command, false)).toBe("needs-key");
expect(commandAvailability(command, true)).toBe("enabled");
}
});
});

describe("slash parsing and filtering", () => {
it("executes only an exact command ID and preserves its argument", () => {
expect(parseSlashCommand("/firecrawl-lead-gen fintech founders")).toMatchObject({
kind: "known",
command: { id: "firecrawl-lead-gen" },
argument: "fintech founders",
});
expect(parseSlashCommand("/firecrawl-lead fintech")).toEqual({
kind: "unknown",
commandId: "firecrawl-lead",
argument: "fintech",
});
});

it("handles empty arguments, mixed case, URLs, and command-like argument text", () => {
expect(parseSlashCommand("/FIRECRAWL")).toMatchObject({ kind: "known", argument: "" });
expect(parseSlashCommand("/firecrawl https://example.com/a?q=x&next=/firecrawl-shop")).toMatchObject({
kind: "known",
argument: "https://example.com/a?q=x&next=/firecrawl-shop",
});
expect(parseSlashCommand("/firecrawl-qa ignore; /firecrawl-shop")).toMatchObject({
kind: "known",
command: { id: "firecrawl-qa" },
argument: "ignore; /firecrawl-shop",
});
});

it("has an explicit leading-whitespace policy and caps user input", () => {
expect(parseSlashCommand(" /firecrawl test")).toEqual({ kind: "none" });
const parsed = parseSlashCommand(`/firecrawl ${"x".repeat(MAX_COMMAND_INPUT + 20)}`);
expect(parsed.kind === "known" ? parsed.argument : "").toHaveLength(MAX_COMMAND_INPUT);
});

it("uses partial matches for filtering only", () => {
expect(filterSlashCommands("/fir")).toHaveLength(17);
expect(filterSlashCommands("/lead").map((command) => command.id)).toEqual([
"firecrawl-lead-gen",
"firecrawl-lead-research",
]);
expect(filterSlashCommands("hello")).toEqual([]);
});
});

describe("workflow setup and app metadata", () => {
function completeValues(commandIndex: number): WorkflowSetupValues {
const command = FIRECRAWL_COMMANDS[commandIndex];
const values = initialWorkflowValues(command, "https://example.com");
for (const field of FIRECRAWL_WORKFLOW_SETUPS[command.workflowId].fields) {
if (field.type === "checkbox") values[field.id] = true;
else if (field.type === "number") values[field.id] = field.min ?? 1;
else if (field.type === "select") values[field.id] = field.options?.[0]?.value ?? "quick";
else if (field.type === "url") values[field.id] = "https://example.com";
else if (!values[field.id]) values[field.id] = "bounded public scope";
}
return values;
}

it("has deterministic valid typed inputs for every enabled workflow", () => {
FIRECRAWL_COMMANDS.forEach((command, index) => {
expect(validateWorkflowSetup(command.workflowId, completeValues(index))).toEqual({});
});
});

it("requires the explicit credit confirmation and validates URLs and bounds locally", () => {
expect(validateWorkflowSetup("firecrawl-deep-research", { topic: "AI", depth: "quick" })).toHaveProperty("confirmCredits");
expect(validateWorkflowSetup("firecrawl-seo-audit", {
siteUrl: "file:///private/data",
boundary: "one section",
pageCap: 101,
depth: "quick",
confirmCredits: true,
})).toMatchObject({ siteUrl: expect.any(String), pageCap: expect.any(String) });
const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-deep-research")!;
expect(() => commandToApp(command, "AI", { topic: "AI", depth: "quick" })).toThrow(
"Workflow setup must be complete before launch",
);
});

it("creates a stable provider/workflow app without secrets or export claims", () => {
const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-lead-research")!;
const values = { ...completeValues(FIRECRAWL_COMMANDS.indexOf(command)), apiKey: "must-not-leak" };
const first = commandToApp(command, "Small public company", values);
const second = commandToApp(command, "Small public company", values);
expect(first.id).toBe(second.id);
expect(first).toMatchObject({ providerId: "firecrawl", workflowId: command.id });
expect(first.request).toContain("Small public company");
expect(first.request).not.toContain("must-not-leak");
expect(first.request).toContain("Do not claim");
});
});

describe("CommandMenu and WorkflowSetup rendering", () => {
let tree: ReactTestRenderer | undefined;
afterEach(() => {
if (tree) act(() => tree?.unmount());
tree = undefined;
});

function renderMenu(text: string, hasProviderKey: boolean, onSelect = jest.fn(), onNeedsKey = jest.fn()) {
act(() => {
tree = create(
<CommandMenu
text={text}
hasProviderKey={hasProviderKey}
highlightedIndex={0}
onHighlightedIndexChange={jest.fn()}
onSelect={onSelect}
onNeedsKey={onNeedsKey}
onDismiss={jest.fn()}
/>,
);
});
return { onSelect, onNeedsKey };
}

it("renders filtered, selected, key-required, empty, and accessible states", () => {
const callbacks = renderMenu("/lead", false);
const rows = tree!.root.findAll((node) => node.props.accessibilityLabel?.startsWith("/firecrawl-lead") && typeof node.props.onPress === "function");
expect(new Set(rows.map((row) => row.props.accessibilityLabel)).size).toBe(2);
expect(rows[0].props.accessibilityState).toMatchObject({ selected: true, disabled: false });
expect(rows[0].props.accessibilityLabel).toContain("Connect key");
act(() => rows[0].props.onPress());
expect(callbacks.onNeedsKey).toHaveBeenCalledWith(expect.objectContaining({ id: "firecrawl-lead-gen" }));
act(() => tree!.unmount());
tree = undefined;
renderMenu("/not-a-command", true);
expect(tree!.root.findByProps({ accessibilityLabel: "No matching slash commands" })).toBeTruthy();
});

it("supports deterministic up/down wrapping and enabled tap selection", () => {
expect(moveCommandSelection(0, -1, 17)).toBe(16);
expect(moveCommandSelection(16, 1, 17)).toBe(0);
const callbacks = renderMenu("/lead", true);
const row = tree!.root.findAll((node) => node.props.accessibilityLabel?.startsWith("/firecrawl-lead") && typeof node.props.onPress === "function")[0];
act(() => row.props.onPress());
expect(callbacks.onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "firecrawl-lead-gen" }));
});

it("renders unavailable rows as disabled with an explanation", () => {
const command = FIRECRAWL_COMMANDS[0] as { availability: "enabled" | "unavailable" };
command.availability = "unavailable";
try {
renderMenu("/firecrawl", true);
const row = tree!.root.findAll((node) => node.props.accessibilityLabel?.startsWith("/firecrawl,") && typeof node.props.onPress === "function")[0];
expect(row.props.accessibilityState.disabled).toBe(true);
expect(row.props.accessibilityLabel).toContain("Unavailable");
} finally {
command.availability = "enabled";
}
});

it("does not submit incomplete onboarding or make a provider call", () => {
const onSubmit = jest.fn();
const command = FIRECRAWL_COMMANDS.find((candidate) => candidate.id === "firecrawl-deep-research")!;
act(() => {
tree = create(<WorkflowSetup command={command} argument="" onCancel={jest.fn()} onSubmit={onSubmit} />);
});
const run = tree!.root.findByProps({ accessibilityLabel: "Run with maximum 100 credits" });
act(() => run.props.onPress());
expect(onSubmit).not.toHaveBeenCalled();
expect(JSON.stringify(tree!.toJSON())).toContain("is required");
});
});
Loading