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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules
dist
*.js.map
/docker-compose.yml
*.tgz
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ delega tasks create "content" --due "2026-03-15"
delega tasks show <id> # Show task details
delega tasks complete <id> # Mark task as completed
delega tasks delete <id> # Delete a task
delega tasks delegate <task-id> <agent-id> # Delegate to another agent
delega tasks delegate <task-id> <agent-id> --content "subtask description"
```

Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"scripts": {
"build": "tsc && node scripts/copy-templates.mjs",
"dev": "ts-node --esm src/index.ts"
"dev": "npx tsx src/index.ts"
},
"dependencies": {
"chalk": "^5.3.0",
Expand Down Expand Up @@ -39,7 +39,7 @@
"cli"
],
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"publishConfig": {
"access": "public",
Expand Down
9 changes: 8 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,14 @@ export async function apiRequest<T = unknown>(
path: string,
body?: unknown,
): Promise<ApiResponse<T>> {
const apiKey = getApiKey();
let apiKey: string | undefined;
try {
apiKey = getApiKey();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`Configuration error: ${msg}`);
process.exit(1);
}
if (!apiKey) {
console.error("Not authenticated. Run: delega login");
process.exit(1);
Expand Down
33 changes: 22 additions & 11 deletions src/commands/agents.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Command } from "commander";
import chalk from "chalk";
import { apiCall } from "../api.js";
import { apiCall, apiRequest } from "../api.js";
import { printTable, formatDate, label, confirm } from "../ui.js";

interface Agent {
Expand Down Expand Up @@ -94,21 +94,32 @@ Examples:
$ delega agents rotate abc123 --yes Skip confirmation (for scripts/agents)
$ delega agents rotate abc123 --json Get new API key as JSON
$ delega agents rotate abc123 --dry-run Preview without rotating
`)
`)
.action(async (id: string, opts) => {
if (opts.dryRun) {
let agent: Agent | undefined;
try {
agent = await apiCall<Agent>("GET", `/agents/${id}`);
} catch {
// Graceful degradation: if GET fails, just show the ID
}
const result = await apiRequest<Agent>("GET", `/agents/${id}`);
const agent = result.ok ? (result.data as Agent) : undefined;
if (opts.json) {
console.log(JSON.stringify({ dry_run: true, agent_id: id, agent_name: agent?.name ?? null, action: "rotate-key" }, null, 2));
console.log(
JSON.stringify(
{
dry_run: true,
agent_id: id,
agent_name: agent ? (agent.display_name || agent.name) : null,
action: "rotate-key",
},
null,
2,
),
);
return;
}
const display = agent?.name ? `${agent.name} (${id})` : id;
console.log(`Would rotate API key for agent ${display}. Old key would stop working immediately.`);
if (agent) {
console.log(`Would rotate API key for agent "${agent.display_name || agent.name}" (${id}).`);
} else {
console.log(`Would rotate API key for agent ${id}.`);
}
console.log("No changes made.");
return;
}
if (!opts.yes) {
Expand Down
6 changes: 5 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,11 @@ async function runSelfHostedSetup(): Promise<SetupResult> {

console.log(chalk.dim("Starting Delega with Docker..."));
startDockerCompose(node_path.dirname(composePath));
console.log(
chalk.dim(
"Delega is bound to localhost only. To expose on your network, edit docker-compose.yml.",
),
);

console.log();
console.log(chalk.dim("Waiting for the local API health check..."));
Expand Down Expand Up @@ -735,7 +740,6 @@ export const initCommand = new Command("init")
.addHelpText("after", `
Examples:
$ delega init Interactive setup wizard
$ delega init --api-url <url> Use a custom API URL

This command walks you through:
1. Choosing hosted (api.delega.dev) or self-hosted (Docker) deployment
Expand Down
9 changes: 4 additions & 5 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Command } from "commander";
import node_readline from "node:readline";
import { saveConfig, loadConfig, normalizeApiUrl, persistApiKey } from "../config.js";
import { getApiUrl, loadConfig, persistApiKey, saveConfig } from "../config.js";
import { formatNetworkError } from "../api.js";
import { printBanner } from "../ui.js";

Expand Down Expand Up @@ -64,12 +64,11 @@ Examples:
}

// Validate by calling the API
const config = loadConfig();
let config: ReturnType<typeof loadConfig>;
let apiUrl: string;
try {
apiUrl = normalizeApiUrl(
config.api_url || process.env.DELEGA_API_URL || "https://api.delega.dev",
);
config = loadConfig();
apiUrl = getApiUrl();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`Configuration error: ${msg}`);
Expand Down
82 changes: 38 additions & 44 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,69 +38,63 @@ Examples:
$ delega status --json Output as JSON (for scripting)
`)
.action(async (opts) => {
// 1. Check for API key (non-fatal — we can still show health info)
const apiKey = getApiKey();

// 2. Resolve API URL
let apiKey: string | undefined;
let apiUrl: string;
try {
// 1. Check for API key (non-fatal — we can still show health info)
apiKey = getApiKey();

// 2. Resolve API URL
apiUrl = getApiUrl();
} catch (err) {
console.error(`Configuration error: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);

// 3. Hit /health (unauthenticated — just checks connectivity)
let healthy = false;
let serverVersion: string | undefined;
let me: MeResponse | undefined;
let stats: Stats | undefined;

try {
const res = await fetch(apiUrl + "/health", {
signal: AbortSignal.timeout(15_000),
});
if (res.ok) {
healthy = true;
try {
const data = await res.json() as HealthResponse;
serverVersion = data.version;
} catch { /* health may return empty 200 */ }
}
} catch {
healthy = false;
}

// 4. Hit /agent/me (authenticated) — direct fetch so network errors don't exit
if (healthy && apiKey) {
const authHeaders = { "X-Agent-Key": apiKey, "Content-Type": "application/json" };
try {
const res = await fetch(apiUrl + "/health", {
signal: controller.signal,
const meRes = await fetch(apiUrl + "/agent/me", {
headers: authHeaders,
signal: AbortSignal.timeout(15_000),
});
if (res.ok) {
healthy = true;
try {
const data = await res.json() as HealthResponse;
serverVersion = data.version;
} catch { /* health may return empty 200 */ }
if (meRes.ok) {
me = await meRes.json() as MeResponse;
}
} catch {
healthy = false;
}
} catch { /* graceful degradation — show partial output */ }

// 4. Hit /agent/me (authenticated) — direct fetch so network errors don't exit
if (healthy && apiKey) {
const authHeaders = { "X-Agent-Key": apiKey, "Content-Type": "application/json" };
try {
const meRes = await fetch(apiUrl + "/agent/me", {
headers: authHeaders,
signal: controller.signal,
});
if (meRes.ok) {
me = await meRes.json() as MeResponse;
}
} catch { /* graceful degradation — show partial output */ }

// 5. Hit /stats (authenticated) — direct fetch for same reason
try {
const statsRes = await fetch(apiUrl + "/stats", {
headers: authHeaders,
signal: controller.signal,
});
if (statsRes.ok) {
stats = await statsRes.json() as Stats;
}
} catch { /* graceful degradation */ }
}
} finally {
clearTimeout(timeout);
// 5. Hit /stats (authenticated) — direct fetch for same reason
try {
const statsRes = await fetch(apiUrl + "/stats", {
headers: authHeaders,
signal: AbortSignal.timeout(15_000),
});
if (statsRes.ok) {
stats = await statsRes.json() as Stats;
}
} catch { /* graceful degradation */ }
}

// 6. Output
Expand Down
35 changes: 30 additions & 5 deletions src/commands/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface Task {
content: string;
status: string;
priority: number;
labels?: string[];
labels?: string[] | string;
due_date?: string;
created_at?: string;
updated_at?: string;
Expand All @@ -33,10 +33,26 @@ interface Comment {
created_at?: string;
}

function parsePriority(value: string): number {
const n = parseInt(value, 10);
if (isNaN(n) || n < 1 || n > 4) {
throw new Error("Priority must be 1, 2, 3, or 4.");
}
return n;
}

function parsePositiveInt(value: string): number {
const n = parseInt(value, 10);
if (isNaN(n) || n < 1) {
throw new Error("Must be a positive integer.");
}
return n;
}

const tasksList = new Command("list")
.description("List tasks")
.option("--completed", "Include completed tasks")
.option("--limit <n>", "Limit results", parseInt)
.option("--limit <n>", "Limit results", parsePositiveInt)
.option("--json", "Output raw JSON")
.addHelpText("after", `
Examples:
Expand Down Expand Up @@ -82,7 +98,7 @@ Examples:
const tasksCreate = new Command("create")
.description("Create a new task")
.argument("<content>", "Task content")
.option("--priority <n>", "Priority 1-4 (default: 1)", (v: string) => parseInt(v, 10), 1)
.option("--priority <n>", "Priority 1-4 (default: 1)", parsePriority, 1)
.option("--labels <labels>", "Comma-separated labels")
.option("--due <date>", "Due date (YYYY-MM-DD)")
.option("--json", "Output raw JSON")
Expand Down Expand Up @@ -137,9 +153,18 @@ Examples:
label("Content", task.content);
label("Status", statusBadge(task.status));
label("Priority", priorityBadge(task.priority));
const labels = typeof task.labels === "string" ? JSON.parse(task.labels) : task.labels;
if (labels && labels.length > 0) {
let labels = task.labels;
if (typeof labels === "string") {
try {
labels = JSON.parse(labels) as string[] | string;
} catch {
// Keep malformed labels as the raw string instead of crashing.
}
}
if (Array.isArray(labels) && labels.length > 0) {
label("Labels", labels.join(", "));
} else if (typeof labels === "string" && labels) {
label("Labels", labels);
}
if (task.due_date) label("Due", formatDate(task.due_date));
if (task.assigned_to_agent_id) label("Delegated To", task.assigned_to_agent_id);
Expand Down
21 changes: 20 additions & 1 deletion src/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,20 @@ interface MeResponse {

export const whoamiCommand = new Command("whoami")
.description("Show current authenticated agent")
.option("--json", "Output raw JSON")
.addHelpText("after", `
Examples:
$ delega whoami Show current agent identity
$ delega whoami --json Output as JSON (for scripting)
`)
.action(async () => {
.action(async (opts) => {
const me = await apiRequest<MeResponse>("GET", "/agent/me");
if (me.ok) {
const payload = me.data as MeResponse;
if (opts.json) {
console.log(JSON.stringify(payload, null, 2));
return;
}
const agent = payload.agent;
if (!agent) {
console.error("Current server did not return agent details.");
Expand Down Expand Up @@ -61,6 +67,19 @@ Examples:
}

await apiCall<unknown[]>("GET", "/tasks?completed=true");
if (opts.json) {
console.log(
JSON.stringify(
{
authenticated: true,
server: "Current API does not expose /agent/me",
},
null,
2,
),
);
return;
}
console.log();
label("Authenticated", "yes");
label("Server", "Current API does not expose /agent/me");
Expand Down
7 changes: 5 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,14 @@ export function loadConfig(): DelegaConfig {
if (!node_fs.existsSync(configPath)) {
return {};
}

const raw = node_fs.readFileSync(configPath, "utf-8");
try {
const raw = node_fs.readFileSync(configPath, "utf-8");
return JSON.parse(raw) as DelegaConfig;
} catch {
return {};
throw new Error(
"Configuration file ~/.delega/config.json is corrupted. Fix or delete it, then retry.",
);
}
}

Expand Down
Loading