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
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@

Command-line setup tool for Allowly workspaces.

Use it after a human creates an account, verifies email, and completes billing setup. `allowly login` opens the dashboard, asks the signed-in owner to approve CLI access, then stores a local CLI credential in `~/.allowly/config.json` with owner-only permissions.
Use it after a human creates an account and verifies email. Billing is not required for setup or the first successful runtime check; later new checks require a payment method. `allowly login` opens the dashboard, asks the signed-in owner to approve CLI access, then stores a local CLI credential in `~/.allowly/config.json` with owner-only permissions.

After the public npm package is released:
After approval, the saved CLI credential can ask the app's AI drafting service for a local setup file:

```bash
allowly init --ai "Allow listing calendar events and require confirmation before deleting events."
```

This only writes `allowly.setup.json`. Review it first; the existing `actions apply` and `policies apply` commands create workspace resources.

Install the public npm package:

```bash
npm install -g @allowly-ai/cli
Expand All @@ -14,14 +22,16 @@ Then:

```bash
allowly login
allowly init --use-case email-agent
allowly init --ai "Allow listing calendar events and confirm before deleting events."
allowly actions apply allowly.setup.json
allowly policies apply allowly.setup.json
allowly keys create --write-env .env.local --var ALLOWLY_API_KEY
allowly setup guide
allowly check --authorization-id auth_... --action web.search --runtime-env .env.local
```

Use `allowly init --use-case email-agent` instead when you want a built-in seed rather than AI drafting.

`allowly login` talks to the dashboard app for browser approval and stores the public API URL returned by Allowly for setup calls. Use `--app-url` for local app development and `--api-url` only when you need to override the API URL written to the local CLI config.

## Optional use-case seeds
Expand All @@ -47,6 +57,6 @@ Use `--write-env` for local env-file output. `--env-file` is intentionally not d

`allowly check` is a runtime helper. It requires a runtime API key from `--api-key`, `ALLOWLY_API_KEY`, or `--runtime-env`; it does not use the CLI setup credential. Receipt signing still happens server-side in the Allowly API.

The command prints the runtime response unchanged. Signed receipts use wire format
`3` (`schema_version`): `alg` and `key_id` are signed top-level fields, and `signature` is the
The command prints the runtime response unchanged. Signed receipts carry a
`schema_version`; `alg` and `key_id` are signed top-level fields, and `signature` is the
unpadded base64url string. Use an Allowly SDK verifier for offline verification.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@allowly-ai/cli",
"version": "0.1.1",
"version": "0.1.2",
"description": "Command-line setup tool for Allowly workspaces",
"type": "module",
"bin": {
Expand Down
6 changes: 5 additions & 1 deletion src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";

import { readConfig, removeConfig, writeConfig } from "./config.js";
import { DEFAULT_APP_URL, readConfig, removeConfig, writeConfig } from "./config.js";

const dirs: string[] = [];

Expand All @@ -18,6 +18,7 @@ test("writeConfig stores CLI access token with owner-only permissions", async ()

await writeConfig({
apiUrl: "https://api.allowly.ai/",
appUrl: "http://localhost:3000/",
accessToken: "allowly_t1_s001_cli_secret",
expiresAt: "2026-07-22T00:00:00Z",
workspaceId: "ws_test",
Expand All @@ -27,6 +28,7 @@ test("writeConfig stores CLI access token with owner-only permissions", async ()
const saved = JSON.parse(await readFile(path, "utf8"));
expect(saved).toEqual({
apiUrl: "https://api.allowly.ai",
appUrl: "http://localhost:3000",
accessToken: "allowly_t1_s001_cli_secret",
expiresAt: "2026-07-22T00:00:00Z",
workspaceId: "ws_test",
Expand All @@ -35,6 +37,7 @@ test("writeConfig stores CLI access token with owner-only permissions", async ()
expect((await stat(path)).mode & 0o777).toBe(0o600);
await expect(readConfig(path)).resolves.toEqual({
apiUrl: "https://api.allowly.ai",
appUrl: "http://localhost:3000",
accessToken: "allowly_t1_s001_cli_secret",
expiresAt: "2026-07-22T00:00:00Z",
workspaceId: "ws_test",
Expand All @@ -55,6 +58,7 @@ test("readConfig still accepts old manual setup-token config", async () => {

await expect(readConfig(path)).resolves.toEqual({
apiUrl: "https://api.allowly.ai",
appUrl: DEFAULT_APP_URL,
accessToken: "allowly_t1_s001_setup_secret",
expiresAt: undefined,
workspaceId: undefined,
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const DEFAULT_APP_URL = "https://app.allowly.ai";

export interface CliConfig {
apiUrl: string;
appUrl?: string;
accessToken: string;
expiresAt?: string;
workspaceId?: string;
Expand Down Expand Up @@ -39,6 +40,7 @@ export async function readConfig(path = configPath()): Promise<CliConfig> {
}
return {
apiUrl: parsed.apiUrl.replace(/\/$/, ""),
appUrl: (parsed.appUrl ?? DEFAULT_APP_URL).replace(/\/$/, ""),
accessToken,
expiresAt: parsed.expiresAt,
workspaceId: parsed.workspaceId,
Expand All @@ -53,6 +55,7 @@ export async function writeConfig(config: CliConfig, path = configPath()): Promi
JSON.stringify(
{
apiUrl: config.apiUrl.replace(/\/$/, ""),
appUrl: config.appUrl?.replace(/\/$/, ""),
accessToken: config.accessToken,
expiresAt: config.expiresAt,
workspaceId: config.workspaceId,
Expand Down
3 changes: 2 additions & 1 deletion src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export async function apiRequest<T>(
method: string,
path: string,
body?: unknown,
timeoutMs = 30_000,
): Promise<T> {
const response = await fetch(`${config.apiUrl}${path}`, {
method,
Expand All @@ -30,7 +31,7 @@ export async function apiRequest<T>(
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
signal: AbortSignal.timeout(timeoutMs),
});

if (response.status === 204) return undefined as T;
Expand Down
53 changes: 51 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { chmod, readFile, writeFile } from "node:fs/promises";
import { access, chmod, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { createInterface } from "node:readline/promises";
import { setTimeout as delay } from "node:timers/promises";
Expand All @@ -16,7 +16,9 @@ import {
getSetupTemplate,
isSetupTemplateName,
loadSetupConfig,
setupConfigFromPolicyDraft,
writeSampleSetupConfig,
type PolicyDraft,
type SetupTemplateName,
} from "./setupConfig.js";

Expand Down Expand Up @@ -52,7 +54,9 @@ interface DeviceTokenResponse {
function usage(): string {
return `Allowly CLI

Allowly is API-first after account, email, and billing setup.
Allowly is API-first after account and email verification.
Billing is not required for setup or the first successful runtime check;
later new checks require a payment method.
Run allowly login once, approve the CLI in your browser, then let Codex,
Claude Code, or a script configure actions, policies, and runtime API keys
without dashboard or billing access.
Expand All @@ -62,6 +66,7 @@ Commands:
allowly logout
allowly --version
allowly status
allowly init --ai "<describe the policy>" [--file allowly.setup.json]
allowly init [--use-case ${SETUP_TEMPLATE_NAMES.join("|")}] [--file allowly.setup.json]
allowly init --list-use-cases
allowly init --manual
Expand Down Expand Up @@ -187,12 +192,14 @@ async function commandLogin(args: string[]): Promise<void> {
const configuredApiUrl = apiUrlOverride ?? authorized.api_url ?? DEFAULT_API_URL;
await writeConfig({
apiUrl: configuredApiUrl,
appUrl,
accessToken: authorized.access_token,
expiresAt: authorized.expires_at,
workspaceId: authorized.workspace_id,
workspaceName: authorized.workspace_name,
});
console.log(`Allowly CLI configured for ${authorized.workspace_name} (${configuredApiUrl.replace(/\/$/, "")})`);
console.log('Next: allowly init --ai "Describe the agent and what needs approval."');
return;
}
throw new AllowlyCliError("CLI login code expired. Run `allowly login` again.", 401, "authorization_expired");
Expand Down Expand Up @@ -342,6 +349,48 @@ async function commandInit(args: string[]): Promise<void> {
listUseCases();
return;
}
if (args.includes("--ai")) {
const config = await readConfig();
if (!config.workspaceId) {
throw new Error("AI policy drafting requires an authenticated workspace. Run `allowly login` again.");
}
let description = option(args, "--ai");
if (!description) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error('Missing AI policy description. Use: allowly init --ai "Describe the policy"');
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
description = await rl.question("Describe the agent and what needs approval: ");
} finally {
rl.close();
}
}
description = description.trim();
if (description.length < 20 || description.length > 4000) {
throw new Error("AI policy description must be between 20 and 4000 characters.");
}
const file = option(args, "--file") ?? "allowly.setup.json";
try {
await access(file);
console.log(`${file} already exists`);
printApplyNextSteps(file);
return;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
const result = await apiRequest<{ draft: PolicyDraft }>(
{ ...config, apiUrl: config.appUrl ?? DEFAULT_APP_URL },
"POST",
"/v1/cli/policies/draft",
{ workspace_id: config.workspaceId, description },
60_000,
);
await writeSampleSetupConfig(file, setupConfigFromPolicyDraft(result.draft));
console.log(`Created ${file} from an AI draft; no workspace resources were changed.`);
printApplyNextSteps(file);
return;
}
if (args.includes("--manual") || args.includes("--self")) {
printManualNextSteps();
return;
Expand Down
72 changes: 72 additions & 0 deletions src/setupConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SETUP_TEMPLATE_NAMES,
getSetupTemplate,
loadSetupConfig,
setupConfigFromPolicyDraft,
writeSampleSetupConfig,
} from "./setupConfig.js";

Expand Down Expand Up @@ -71,6 +72,77 @@ test("loadSetupConfig rejects policies without actions", async () => {
await expect(loadSetupConfig(path)).rejects.toThrow("must include at least one action");
});

test("AI policy drafts become local setup files without creating resources", () => {
const setup = setupConfigFromPolicyDraft({
policy_id: "calendar_assistant_policy",
agent_id: "calendar_assistant",
description: "List calendar events and confirm before deleting them.",
actions: [
{
name: "calendar.event.list",
description: "List calendar events",
approval_mode: "allow",
escalation_to: "",
context_fields: [],
constraints: { deny_when: [], confirm_when: [], escalate_when: [] },
is_new: true,
},
{
name: "calendar.event.delete",
description: "Delete a calendar event",
approval_mode: "confirm",
escalation_to: "owner",
context_fields: [{ name: "risk", type: "integer" }],
constraints: {
deny_when: [{ field: "risk", op: "gte", value: 9 }],
confirm_when: [],
escalate_when: [{ field: "risk", op: "gte", value: 7 }],
},
is_new: true,
},
],
});

expect(setup).toEqual({
actions: [
{
name: "calendar.event.list",
description: "List calendar events",
requires_confirm: false,
requires_escalation: false,
constraints_schema: {},
},
{
name: "calendar.event.delete",
description: "Delete a calendar event",
requires_confirm: true,
requires_escalation: false,
constraints_schema: { context_fields: { risk: "integer" } },
},
],
policies: [{
policy_id: "calendar_assistant_policy",
agent_id: "calendar_assistant",
description: "List calendar events and confirm before deleting them.",
actions: [
{ name: "calendar.event.list", constraints: {} },
{
name: "calendar.event.delete",
constraints: {
deny_when: [{ field: "risk", gte: 9 }],
escalate_when: [{ field: "risk", gte: 7 }],
},
},
],
requires_confirm_for: ["calendar.event.delete"],
requires_escalation_for: [],
requires_deny_for: [],
escalation_targets: { "calendar.event.delete": "owner" },
default_expiry_days: 365,
}],
});
});

test("starter policies are valid setup configs", async () => {
for (const useCaseName of SETUP_TEMPLATE_NAMES) {
const dir = await mkdtemp(join(tmpdir(), "allowly-policy-"));
Expand Down
Loading
Loading