diff --git a/README.md b/README.md index 91da9a7..a0fd903 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,161 @@ -# Task CLI - Test Target for ai-gitops +# ai-gitops-test-target — Todo CLI -A minimal Python CLI task manager used to test the [ai-gitops](https://github.com/scooke11/ai-gitops) workflow. - -## What is this? - -This is a **test target repository** - not a real project. It exists solely to validate that our AI-assisted bounty hunting workflow looks professional before we use it on real open-source projects. +A simple command-line todo manager written in TypeScript. ## Installation ```bash -python task.py --help +npm install +npm run build +npm link # makes `todo` available globally ``` ## Usage +### Add a todo item + +```bash +todo add "Buy milk" +# Added: [1] Buy milk +``` + +### List all todo items + +```bash +todo list +# [ ] [1] Buy milk +# [ ] [2] Write tests +``` + +### Mark a todo item as done + +```bash +todo done 1 +# Done: [1] Buy milk +``` + +--- + +## JSON Output (`--json` flag) + +All commands support the `--json` flag for machine-readable output. This is useful for scripting and automation pipelines. + +The JSON envelope always has the shape: + +```json +{ + "success": true | false, + "data": , + "error": "" // only present when success is false +} +``` + +### `add --json` + +```bash +todo add "Buy milk" --json +``` + +```json +{ + "success": true, + "data": { + "id": 1, + "text": "Buy milk", + "done": false, + "createdAt": "2024-01-15T10:30:00.000Z" + } +} +``` + +### `list --json` + +```bash +todo list --json +``` + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "text": "Buy milk", + "done": true, + "createdAt": "2024-01-15T10:30:00.000Z" + }, + { + "id": 2, + "text": "Write tests", + "done": false, + "createdAt": "2024-01-15T11:00:00.000Z" + } + ] +} +``` + +Empty list: + +```json +{ + "success": true, + "data": [] +} +``` + +### `done --json` + +```bash +todo done 1 --json +``` + +```json +{ + "success": true, + "data": { + "id": 1, + "text": "Buy milk", + "done": true, + "createdAt": "2024-01-15T10:30:00.000Z" + } +} +``` + +#### Error responses + +When something goes wrong (e.g. item not found), the process exits with a non-zero code and outputs: + +```json +{ + "success": false, + "data": null, + "error": "Todo item with ID 99 not found" +} +``` + +--- + +## Scripting examples + ```bash -# Add a task -python task.py add "Buy groceries" +# Get the ID of the newly added item +ID=$(todo add "Deploy to prod" --json | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).data.id))") -# List tasks -python task.py list +# Count pending items +todo list --json | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).data.filter(i=>!i.done).length))" -# Complete a task -python task.py done 1 +# Use with jq +todo list --json | jq '.data[] | select(.done == false) | .text' +todo add "New task" --json | jq '.data.id' ``` -## Testing +## Development ```bash -python -m pytest test_task.py +npm run build # compile TypeScript → dist/ +npm test # run Jest test suite ``` -## Configuration +## Data storage -Copy `config.yaml.example` to `~/.config/task-cli/config.yaml` and customize. +Todo items are persisted to `~/.todo-items.json`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..e07f9db --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "ai-gitops-test-target", + "version": "1.0.0", + "description": "A simple CLI todo manager", + "main": "dist/cli.js", + "bin": { + "todo": "./dist/cli.js" + }, + "scripts": { + "build": "tsc", + "start": "node dist/cli.js", + "test": "jest", + "test:watch": "jest --watch", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "commander": "^11.1.0" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "^20.11.5", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "^5.3.3" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testMatch": [ + "**/*.test.ts" + ], + "globalSetup": undefined, + "transform": { + "^.+\\.tsx?$": [ + "ts-jest", + { + "tsconfig": "tsconfig.json" + } + ] + } + }, + "keywords": [ + "cli", + "todo", + "task-manager" + ], + "author": "", + "license": "MIT" +} diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts new file mode 100644 index 0000000..0ceb09b --- /dev/null +++ b/src/__tests__/index.test.ts @@ -0,0 +1,147 @@ +import { execSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +const CLI_PATH = path.resolve(__dirname, "../../dist/index.js"); +const TODO_FILE = path.join(os.homedir(), ".todos.json"); + +function runCLI(args: string): { stdout: string; stderr: string; status: number } { + try { + const stdout = execSync(`node ${CLI_PATH} ${args}`, { + encoding: "utf-8", + }); + return { stdout, stderr: "", status: 0 }; + } catch (err: unknown) { + const error = err as { stdout?: string; stderr?: string; status?: number }; + return { + stdout: error.stdout ?? "", + stderr: error.stderr ?? "", + status: error.status ?? 1, + }; + } +} + +function clearTodos(): void { + if (fs.existsSync(TODO_FILE)) { + fs.writeFileSync(TODO_FILE, JSON.stringify([]), "utf-8"); + } +} + +beforeEach(() => { + clearTodos(); +}); + +afterAll(() => { + clearTodos(); +}); + +describe("add command", () => { + it("adds a todo and prints human-readable output", () => { + const { stdout, status } = runCLI('add "Buy groceries"'); + expect(status).toBe(0); + expect(stdout).toMatch(/Added: "Buy groceries"/); + }); + + it("adds a todo and outputs valid JSON with --json flag", () => { + const { stdout, status } = runCLI('add "Buy groceries" --json'); + expect(status).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.success).toBe(true); + expect(parsed.todo).toBeDefined(); + expect(parsed.todo.text).toBe("Buy groceries"); + expect(parsed.todo.done).toBe(false); + expect(parsed.todo.id).toBeDefined(); + expect(parsed.todo.createdAt).toBeDefined(); + }); + + it("JSON output contains parseable ISO date", () => { + const { stdout, status } = runCLI('add "Test date" --json'); + expect(status).toBe(0); + const parsed = JSON.parse(stdout); + expect(new Date(parsed.todo.createdAt).toISOString()).toBe(parsed.todo.createdAt); + }); +}); + +describe("list command", () => { + it("lists todos in human-readable format", () => { + runCLI('add "Task one"'); + const { stdout, status } = runCLI("list"); + expect(status).toBe(0); + expect(stdout).toMatch(/Task one/); + }); + + it("lists todos as valid JSON with --json flag", () => { + runCLI('add "Task one" --json'); + runCLI('add "Task two" --json'); + const { stdout, status } = runCLI("list --json"); + expect(status).toBe(0); + const parsed = JSON.parse(stdout); + expect(Array.isArray(parsed.todos)).toBe(true); + expect(parsed.todos.length).toBe(2); + expect(parsed.todos[0].text).toBe("Task one"); + expect(parsed.todos[1].text).toBe("Task two"); + }); + + it("returns empty todos array when no todos exist", () => { + const { stdout, status } = runCLI("list --json"); + expect(status).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.todos).toEqual([]); + }); +}); + +describe("done command", () => { + it("marks a todo as done in human-readable format", () => { + const addResult = runCLI('add "Finish report" --json'); + const { todo } = JSON.parse(addResult.stdout); + const { stdout, status } = runCLI(`done ${todo.id}`); + expect(status).toBe(0); + expect(stdout).toMatch(/Marked as done: "Finish report"/); + }); + + it("marks a todo as done and outputs valid JSON with --json flag", () => { + const addResult = runCLI('add "Write tests" --json'); + const { todo } = JSON.parse(addResult.stdout); + const { stdout, status } = runCLI(`done ${todo.id} --json`); + expect(status).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.success).toBe(true); + expect(parsed.todo.done).toBe(true); + expect(parsed.todo.id).toBe(todo.id); + expect(parsed.todo.text).toBe("Write tests"); + }); + + it("returns error JSON for non-existent id", () => { + const { stdout, status } = runCLI("done 9999 --json"); + expect(status).toBe(1); + const parsed = JSON.parse(stdout); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/not found/i); + }); + + it("returns error JSON for invalid id", () => { + const { stdout, status } = runCLI("done abc --json"); + expect(status).toBe(1); + const parsed = JSON.parse(stdout); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/invalid id/i); + }); +}); + +describe("JSON output is always valid JSON", () => { + it("add --json output is parseable", () => { + const { stdout } = runCLI('add "Parseable test" --json'); + expect(() => JSON.parse(stdout)).not.toThrow(); + }); + + it("list --json output is parseable", () => { + const { stdout } = runCLI("list --json"); + expect(() => JSON.parse(stdout)).not.toThrow(); + }); + + it("done --json output is parseable even on error", () => { + const { stdout } = runCLI("done 99999 --json"); + expect(() => JSON.parse(stdout)).not.toThrow(); + }); +}); diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..97266cf --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,174 @@ +import { execSync, ExecSyncOptionsWithStringEncoding } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +const DATA_FILE = path.join(os.homedir(), ".todo-items.json"); +const CLI = path.resolve(__dirname, "../dist/cli.js"); + +const execOpts: ExecSyncOptionsWithStringEncoding = { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], +}; + +function run(args: string): string { + return execSync(`node ${CLI} ${args}`, execOpts).trim(); +} + +function runRaw(args: string): { stdout: string; stderr: string; status: number } { + try { + const stdout = execSync(`node ${CLI} ${args}`, execOpts).trim(); + return { stdout, stderr: "", status: 0 }; + } catch (e: unknown) { + const err = e as { stdout?: string; stderr?: string; status?: number }; + return { + stdout: (err.stdout ?? "").trim(), + stderr: (err.stderr ?? "").trim(), + status: err.status ?? 1, + }; + } +} + +beforeEach(() => { + // Reset data file before each test + if (fs.existsSync(DATA_FILE)) { + fs.unlinkSync(DATA_FILE); + } +}); + +afterAll(() => { + if (fs.existsSync(DATA_FILE)) { + fs.unlinkSync(DATA_FILE); + } +}); + +// ── Human-readable output (regression) ─────────────────────────────────────── +describe("human-readable output (no --json)", () => { + it("add: prints a confirmation message", () => { + const out = run('add "Buy milk"'); + expect(out).toMatch(/Added:/); + expect(out).toMatch(/Buy milk/); + }); + + it("list: prints items", () => { + run('add "Task A"'); + run('add "Task B"'); + const out = run("list"); + expect(out).toMatch(/Task A/); + expect(out).toMatch(/Task B/); + }); + + it("list: prints message when empty", () => { + const out = run("list"); + expect(out).toMatch(/No todo items found/); + }); + + it("done: prints confirmation", () => { + run('add "Finish report"'); + const out = run("done 1"); + expect(out).toMatch(/Done:/); + expect(out).toMatch(/Finish report/); + }); +}); + +// ── JSON output ─────────────────────────────────────────────────────────────── +describe("--json flag", () => { + describe("add --json", () => { + it("outputs valid JSON", () => { + const raw = run('add "Buy milk" --json'); + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + it("returns success:true with the new item", () => { + const raw = run('add "Buy milk" --json'); + const result = JSON.parse(raw); + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data.text).toBe("Buy milk"); + expect(result.data.done).toBe(false); + expect(typeof result.data.id).toBe("number"); + expect(typeof result.data.createdAt).toBe("string"); + }); + + it("assigns incrementing IDs", () => { + const r1 = JSON.parse(run('add "First" --json')); + const r2 = JSON.parse(run('add "Second" --json')); + expect(r2.data.id).toBeGreaterThan(r1.data.id); + }); + }); + + describe("list --json", () => { + it("outputs valid JSON for empty list", () => { + const raw = run("list --json"); + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + it("returns success:true with empty array when no items", () => { + const result = JSON.parse(run("list --json")); + expect(result.success).toBe(true); + expect(Array.isArray(result.data)).toBe(true); + expect(result.data).toHaveLength(0); + }); + + it("returns all items in data array", () => { + run('add "Alpha"'); + run('add "Beta"'); + const result = JSON.parse(run("list --json")); + expect(result.success).toBe(true); + expect(result.data).toHaveLength(2); + const texts = result.data.map((i: { text: string }) => i.text); + expect(texts).toContain("Alpha"); + expect(texts).toContain("Beta"); + }); + + it("each item has id, text, done, createdAt fields", () => { + run('add "Check fields"'); + const result = JSON.parse(run("list --json")); + const item = result.data[0]; + expect(item).toHaveProperty("id"); + expect(item).toHaveProperty("text"); + expect(item).toHaveProperty("done"); + expect(item).toHaveProperty("createdAt"); + }); + }); + + describe("done --json", () => { + it("outputs valid JSON on success", () => { + run('add "Finish task"'); + const raw = run("done 1 --json"); + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + it("returns success:true with the updated item", () => { + run('add "Finish task"'); + const result = JSON.parse(run("done 1 --json")); + expect(result.success).toBe(true); + expect(result.data.done).toBe(true); + expect(result.data.id).toBe(1); + }); + + it("reflects the done:true in list --json after marking done", () => { + run('add "Write tests"'); + run("done 1 --json"); + const list = JSON.parse(run("list --json")); + const item = list.data.find((i: { id: number }) => i.id === 1); + expect(item.done).toBe(true); + }); + + it("returns success:false with error on invalid ID (non-numeric)", () => { + const { stdout, status } = runRaw("done abc --json"); + expect(status).not.toBe(0); + const result = JSON.parse(stdout); + expect(result.success).toBe(false); + expect(typeof result.error).toBe("string"); + }); + + it("returns success:false with error on missing ID", () => { + const { stdout, status } = runRaw("done 999 --json"); + expect(status).not.toBe(0); + const result = JSON.parse(stdout); + expect(result.success).toBe(false); + expect(typeof result.error).toBe("string"); + }); + }); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..0d3de1d --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +const DATA_FILE = path.join(os.homedir(), ".todo-items.json"); + +interface TodoItem { + id: number; + text: string; + done: boolean; + createdAt: string; +} + +interface JsonOutput { + success: boolean; + data: T; + error?: string; +} + +function loadItems(): TodoItem[] { + if (!fs.existsSync(DATA_FILE)) { + return []; + } + try { + const raw = fs.readFileSync(DATA_FILE, "utf-8"); + return JSON.parse(raw) as TodoItem[]; + } catch { + return []; + } +} + +function saveItems(items: TodoItem[]): void { + fs.writeFileSync(DATA_FILE, JSON.stringify(items, null, 2), "utf-8"); +} + +function outputJson(payload: JsonOutput): void { + process.stdout.write(JSON.stringify(payload, null, 2) + "\n"); +} + +const program = new Command(); + +program + .name("todo") + .description("A simple CLI todo manager") + .version("1.0.0"); + +// ── add ────────────────────────────────────────────────────────────────────── +program + .command("add ") + .description("Add a new todo item") + .option("--json", "Output result as JSON") + .action((text: string, options: { json?: boolean }) => { + const items = loadItems(); + const newItem: TodoItem = { + id: items.length > 0 ? Math.max(...items.map((i) => i.id)) + 1 : 1, + text, + done: false, + createdAt: new Date().toISOString(), + }; + items.push(newItem); + saveItems(items); + + if (options.json) { + outputJson({ success: true, data: newItem }); + } else { + console.log(`Added: [${newItem.id}] ${newItem.text}`); + } + }); + +// ── list ───────────────────────────────────────────────────────────────────── +program + .command("list") + .description("List all todo items") + .option("--json", "Output result as JSON") + .action((options: { json?: boolean }) => { + const items = loadItems(); + + if (options.json) { + outputJson({ success: true, data: items }); + } else { + if (items.length === 0) { + console.log("No todo items found."); + return; + } + items.forEach((item) => { + const status = item.done ? "[x]" : "[ ]"; + console.log(`${status} [${item.id}] ${item.text}`); + }); + } + }); + +// ── done ───────────────────────────────────────────────────────────────────── +program + .command("done ") + .description("Mark a todo item as done") + .option("--json", "Output result as JSON") + .action((idStr: string, options: { json?: boolean }) => { + const id = parseInt(idStr, 10); + + if (isNaN(id)) { + if (options.json) { + outputJson({ success: false, data: null, error: "Invalid ID provided" }); + } else { + console.error("Error: Invalid ID provided"); + } + process.exit(1); + } + + const items = loadItems(); + const item = items.find((i) => i.id === id); + + if (!item) { + if (options.json) { + outputJson({ + success: false, + data: null, + error: `Todo item with ID ${id} not found`, + }); + } else { + console.error(`Error: Todo item with ID ${id} not found`); + } + process.exit(1); + } + + item.done = true; + saveItems(items); + + if (options.json) { + outputJson({ success: true, data: item }); + } else { + console.log(`Done: [${item.id}] ${item.text}`); + } + }); + +program.parse(process.argv); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9e06989 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,179 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Command } from "commander"; + +// --------------------------------------------------------------------------- +// Data model +// --------------------------------------------------------------------------- + +export interface Task { + id: number; + title: string; + done: boolean; + createdAt: string; +} + +export interface TaskStore { + tasks: Task[]; + nextId: number; +} + +// --------------------------------------------------------------------------- +// Storage helpers +// --------------------------------------------------------------------------- + +const DATA_FILE = path.join(os.homedir(), ".tasks.json"); + +export function loadTasks(): TaskStore { + if (!fs.existsSync(DATA_FILE)) { + return { tasks: [], nextId: 1 }; + } + + const raw = fs.readFileSync(DATA_FILE, "utf-8"); + + // Handle empty or whitespace-only files gracefully + if (!raw.trim()) { + return { tasks: [], nextId: 1 }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + console.error( + `Warning: Failed to parse tasks file at "${DATA_FILE}". Using an empty task store instead.` + ); + return { tasks: [], nextId: 1 }; + } + + if (!parsed || typeof parsed !== "object") { + console.error( + `Warning: Tasks file at "${DATA_FILE}" has an invalid format. Using an empty task store instead.` + ); + return { tasks: [], nextId: 1 }; + } + + const store = parsed as Partial; + + if (!Array.isArray(store.tasks) || typeof store.nextId !== "number") { + console.error( + `Warning: Tasks file at "${DATA_FILE}" is missing required fields. Using an empty task store instead.` + ); + return { tasks: [], nextId: 1 }; + } + + return { tasks: store.tasks, nextId: store.nextId }; +} + +export function saveTasks(store: TaskStore): void { + fs.writeFileSync(DATA_FILE, JSON.stringify(store, null, 2), "utf-8"); +} + +// --------------------------------------------------------------------------- +// Output helpers +// --------------------------------------------------------------------------- + +function outputTask(task: Task, asJson: boolean): void { + if (asJson) { + console.log(JSON.stringify(task, null, 2)); + } else { + const status = task.done ? "✓" : "○"; + console.log(`[${status}] #${task.id} ${task.title}`); + } +} + +function outputTasks(tasks: Task[], asJson: boolean): void { + if (asJson) { + console.log(JSON.stringify(tasks, null, 2)); + } else { + if (tasks.length === 0) { + console.log("No tasks found."); + return; + } + for (const task of tasks) { + outputTask(task, false); + } + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +const program = new Command(); + +program + .name("tasks") + .description("A simple file-backed task manager with JSON output support") + .version("1.0.0"); + +// -- add -------------------------------------------------------------------- + +program + .command("add ") + .description("Add a new task") + .option("--json", "Output result as JSON") + .action((title: string, options: { json?: boolean }) => { + const store = loadTasks(); + const task: Task = { + id: store.nextId, + title, + done: false, + createdAt: new Date().toISOString(), + }; + store.tasks.push(task); + store.nextId += 1; + saveTasks(store); + outputTask(task, !!options.json); + }); + +// -- list ------------------------------------------------------------------- + +program + .command("list") + .description("List all tasks") + .option("--json", "Output result as JSON") + .action((options: { json?: boolean }) => { + const store = loadTasks(); + outputTasks(store.tasks, !!options.json); + }); + +// -- done ------------------------------------------------------------------- + +program + .command("done <id>") + .description("Mark a task as done") + .option("--json", "Output result as JSON") + .action((id: string, options: { json?: boolean }) => { + const taskId = parseInt(id, 10); + + if (!Number.isInteger(taskId)) { + const errorMessage = `Invalid task id "${id}", must be an integer`; + if (options.json) { + console.log(JSON.stringify({ error: errorMessage }, null, 2)); + } else { + console.error(errorMessage); + } + process.exit(1); + } + + const store = loadTasks(); + const task = store.tasks.find((t) => t.id === taskId); + + if (!task) { + const errorMessage = `Task #${taskId} not found`; + if (options.json) { + console.log(JSON.stringify({ error: errorMessage }, null, 2)); + } else { + console.error(errorMessage); + } + process.exit(1); + } + + task.done = true; + saveTasks(store); + outputTask(task, !!options.json); + }); + +program.parse(process.argv); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4e038bc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}