Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
SUPABASE_URL=
SUPABASE_PUBLISHABLE_KEY=
LISTEE_API_URL=
# LISTEE_CLI_KEYCHAIN_SERVICE=listee-cli
# LISTEE_API_AUTH_BEARER_MODE=user-id
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ Create a `.env` file or export environment variables before running commands:
```bash
export SUPABASE_URL="https://your-project.supabase.co"
export SUPABASE_ANON_KEY="your-anon-key"
export LISTEE_API_URL="https://api.your-listee-instance.dev"
# optional: override the Keytar service name
export LISTEE_CLI_KEYCHAIN_SERVICE="listee-cli"
# optional: choose bearer header value ("user-id" for local API mocks, "access-token" for real JWT)
export LISTEE_API_AUTH_BEARER_MODE="user-id"
```
Comment thread
gentamura marked this conversation as resolved.
Outdated
Never commit secrets; the repo defaults to reading from the process environment.

Expand All @@ -32,6 +35,12 @@ listee auth signup --email you@example.com
listee auth login --email you@example.com
listee auth status
listee auth logout
listee categories list [--email you@example.com]
listee categories show <categoryId> [--email you@example.com]
listee categories create --name "Inbox" [--email you@example.com]
listee tasks list --category <categoryId> [--email you@example.com]
listee tasks create --category <categoryId> --name "Task title" [--description "..."] [--checked] [--email you@example.com]
listee tasks show <taskId> [--email you@example.com]
```

`listee auth signup` starts a temporary local callback server. Leave the command running, open the confirmation email, and the CLI will finish automatically once the browser redirects back to the loopback URL.
Expand All @@ -49,7 +58,12 @@ listee auth logout
src/
index.ts # CLI entrypoint (Commander wiring)
commands/auth.ts # Auth subcommands
commands/categories.ts
commands/tasks.ts
services/auth-service.ts
services/api-client.ts
services/category-api.ts
services/task-api.ts
AGENTS.md # Agent-specific automation guidelines
```

Expand Down
33 changes: 27 additions & 6 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const ensureEmail = (value: unknown): string => {
return ensureNonEmpty(value, "Email");
};

const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null;
};

const handleError = (error: unknown): void => {
if (error instanceof Error) {
console.error(`Error: ${error.message}`);
Expand Down Expand Up @@ -103,12 +107,19 @@ const startLoopbackServer = async (): Promise<LoopbackServer> => {
let settled = false;

const server = createServer((req, res) => {
const finish = (status: number, body: string, contentType = "text/html"): void => {
const finish = (
status: number,
body: string,
contentType = "text/html",
): void => {
res.writeHead(status, { "Content-Type": contentType });
res.end(body);
};

const respondWithJson = (status: number, payload: { title: string; message: string }): void => {
const respondWithJson = (
status: number,
payload: { title: string; message: string },
): void => {
finish(status, JSON.stringify(payload), "application/json");
};

Expand All @@ -131,7 +142,10 @@ const startLoopbackServer = async (): Promise<LoopbackServer> => {
return;
}
try {
const parsed = JSON.parse(data) as { hash?: string };
const parsed = JSON.parse(data);
if (!isRecord(parsed)) {
throw new Error("Invalid request body received.");
}
const hash = parsed.hash;
if (typeof hash !== "string" || hash.length === 0) {
throw new Error("Missing hash in request body.");
Expand Down Expand Up @@ -174,7 +188,11 @@ const startLoopbackServer = async (): Promise<LoopbackServer> => {
});

const address = server.address();
if (address === null || typeof address !== "object" || address.port === undefined) {
if (
address === null ||
typeof address !== "object" ||
address.port === undefined
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
server.close();
throw new Error("Failed to determine loopback server port.");
}
Expand All @@ -200,7 +218,8 @@ const startLoopbackServer = async (): Promise<LoopbackServer> => {

return {
redirectUrl: `http://${LOOPBACK_HOST}:${address.port}/callback`,
waitForConfirmation: () => waitForConfirmation.finally(() => clearTimeout(timeout)),
waitForConfirmation: () =>
waitForConfirmation.finally(() => clearTimeout(timeout)),
shutdown,
};
};
Expand Down Expand Up @@ -344,7 +363,9 @@ const signupAction = async (options: EmailOption): Promise<void> => {

try {
await signup(email, password, loopback.redirectUrl);
console.log("📩 Confirmation email sent. Keep this terminal open while you click the link.");
console.log(
"📩 Confirmation email sent. Keep this terminal open while you click the link.",
);
const result = await loopback.waitForConfirmation();
console.log(`✅ Signup confirmed for ${result.account}.`);
} finally {
Expand Down
158 changes: 158 additions & 0 deletions src/commands/categories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { Command } from "commander";
import {
createCategory,
getCategory,
listCategories,
} from "../services/category-api.js";

const ensurePositiveInteger = (value: string): number => {
if (!/^\d+$/.test(value)) {
throw new Error("Limit must be a positive integer.");
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error("Limit must be a positive integer.");
}
return parsed;
};

const ensureNonEmptyString = (value: string, label: string): string => {
const trimmed = value.trim();
if (trimmed.length === 0) {
throw new Error(`${label} must not be empty.`);
}
return trimmed;
};

const execute = <T extends unknown[]>(task: (...args: T) => Promise<void>) => {
return async (...args: T): Promise<void> => {
try {
await task(...args);
} catch (error) {
if (error instanceof Error) {
console.error(`Error: ${error.message}`);
} else {
console.error("Unknown error occurred.");
}
process.exitCode = 1;
}
};
};

const printCategories = (
items: readonly {
readonly id: string;
readonly name: string;
readonly kind: string;
}[],
): void => {
if (items.length === 0) {
console.log("No categories found.");
return;
}

console.log("Categories:");
for (const item of items) {
console.log(` • ${item.name} (${item.id}) [${item.kind}]`);
}
};

const printCategoryDetails = (category: {
readonly name: string;
readonly id: string;
readonly kind: string;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdAt: string;
readonly updatedAt: string;
}): void => {
console.log(`Name: ${category.name}`);
console.log(`ID: ${category.id}`);
console.log(`Kind: ${category.kind}`);
console.log(`Created By: ${category.createdBy}`);
console.log(`Updated By: ${category.updatedBy}`);
console.log(`Created At: ${category.createdAt}`);
console.log(`Updated At: ${category.updatedAt}`);
};

export const registerCategoryCommand = (program: Command): void => {
const categories = program
.command("categories")
.description("Inspect Listee categories via the API.");

categories
.command("list")
.description("List categories for the authenticated user.")
.option("--email <email>", "Account email to use when fetching categories")
.option("--limit <limit>", "Maximum number of categories to fetch")
.option("--cursor <cursor>", "Cursor returned by a previous list operation")
.action(
execute(
async (options: {
readonly email?: string;
readonly limit?: string;
readonly cursor?: string;
}) => {
const limit =
options.limit === undefined
? undefined
: ensurePositiveInteger(options.limit);
const result = await listCategories({
email: options.email,
limit,
cursor: options.cursor ?? null,
});
printCategories(result.data);
if (result.meta.hasMore) {
const cursorValue = result.meta.nextCursor ?? "";
console.log(
"More categories available. Use --cursor",
cursorValue,
"to continue.",
);
}
},
),
);

categories
.command("show <categoryId>")
.description("Show details for a specific category.")
.option(
"--email <email>",
"Account email to use when fetching the category",
)
.action(
execute(
async (categoryId: string, options: { readonly email?: string }) => {
const response = await getCategory({
email: options.email,
categoryId,
});
printCategoryDetails(response.data);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
),
);

categories
.command("create")
.description("Create a new category for the authenticated user.")
.requiredOption("--name <name>", "Name of the category to create")
.option(
"--email <email>",
"Account email to use when creating the category",
)
.action(
execute(
async (options: { readonly name: string; readonly email?: string }) => {
const name = ensureNonEmptyString(options.name, "Name");
const category = await createCategory({
email: options.email,
name,
});
console.log("Category created.");
printCategoryDetails(category);
},
),
);
};
Loading