@da1z/chop 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/use-bun-instead-of-node-vite-npm-pnpm.md +109 -0
- package/.claude/settings.local.json +12 -0
- package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
- package/.devcontainer/Dockerfile +102 -0
- package/.devcontainer/devcontainer.json +58 -0
- package/.devcontainer/init-firewall.sh +137 -0
- package/.github/workflows/publish.yml +76 -0
- package/CLAUDE.md +44 -0
- package/README.md +15 -0
- package/index.ts +2 -0
- package/loop.sh +206 -0
- package/package.json +27 -0
- package/specs/chop.md +313 -0
- package/src/commands/add.ts +74 -0
- package/src/commands/archive.ts +72 -0
- package/src/commands/completion.ts +232 -0
- package/src/commands/done.ts +38 -0
- package/src/commands/edit.ts +228 -0
- package/src/commands/init.ts +72 -0
- package/src/commands/list.ts +48 -0
- package/src/commands/move.ts +92 -0
- package/src/commands/pop.ts +45 -0
- package/src/commands/purge.ts +41 -0
- package/src/commands/show.ts +32 -0
- package/src/commands/status.ts +43 -0
- package/src/config/paths.ts +61 -0
- package/src/errors.ts +56 -0
- package/src/index.ts +41 -0
- package/src/models/id-generator.ts +39 -0
- package/src/models/task.ts +98 -0
- package/src/storage/file-lock.ts +124 -0
- package/src/storage/storage-resolver.ts +63 -0
- package/src/storage/task-store.ts +173 -0
- package/src/types.ts +42 -0
- package/src/utils/display.ts +139 -0
- package/src/utils/git.ts +80 -0
- package/src/utils/prompts.ts +88 -0
- package/tests/errors.test.ts +86 -0
- package/tests/models/id-generator.test.ts +46 -0
- package/tests/models/task.test.ts +186 -0
- package/tests/storage/file-lock.test.ts +152 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { TaskStore } from "../storage/task-store.ts";
|
|
3
|
+
|
|
4
|
+
// Generate bash completion script
|
|
5
|
+
function generateBashCompletion(): string {
|
|
6
|
+
return `# Bash completion for chop
|
|
7
|
+
# Add this to your ~/.bashrc or ~/.bash_profile:
|
|
8
|
+
# eval "$(chop completion bash)"
|
|
9
|
+
# or: eval "$(ch completion bash)"
|
|
10
|
+
|
|
11
|
+
_chop_completions() {
|
|
12
|
+
local cur prev words cword
|
|
13
|
+
|
|
14
|
+
# Use bash-completion if available, otherwise fallback to basic parsing
|
|
15
|
+
if declare -F _init_completion >/dev/null 2>&1; then
|
|
16
|
+
_init_completion || return
|
|
17
|
+
else
|
|
18
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
19
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
20
|
+
words=("\${COMP_WORDS[@]}")
|
|
21
|
+
cword=$COMP_CWORD
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
local commands="init add a list ls pop p done d status move mv mt mb archive ar purge edit e show s completion"
|
|
25
|
+
|
|
26
|
+
# If completing the first argument (command name)
|
|
27
|
+
if [[ $cword -eq 1 ]]; then
|
|
28
|
+
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
|
29
|
+
return
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# If completing argument for commands that need task ID
|
|
33
|
+
local cmd="\${words[1]}"
|
|
34
|
+
case "$cmd" in
|
|
35
|
+
done|d|move|mv|mt|mb|archive|ar|edit|e|show|s)
|
|
36
|
+
# Get task IDs from chop
|
|
37
|
+
local task_ids
|
|
38
|
+
task_ids=$(chop completion --list-ids 2>/dev/null)
|
|
39
|
+
COMPREPLY=($(compgen -W "$task_ids" -- "$cur"))
|
|
40
|
+
return
|
|
41
|
+
;;
|
|
42
|
+
esac
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
complete -F _chop_completions chop
|
|
46
|
+
complete -F _chop_completions ch
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Generate zsh completion script
|
|
51
|
+
function generateZshCompletion(): string {
|
|
52
|
+
return `#compdef chop ch
|
|
53
|
+
# Zsh completion for chop
|
|
54
|
+
# Add this to your ~/.zshrc:
|
|
55
|
+
# eval "$(chop completion zsh)"
|
|
56
|
+
# or: eval "$(ch completion zsh)"
|
|
57
|
+
|
|
58
|
+
_chop() {
|
|
59
|
+
local -a commands
|
|
60
|
+
commands=(
|
|
61
|
+
'init:Initialize chop in current directory'
|
|
62
|
+
'add:Add a new task'
|
|
63
|
+
'a:Add a new task (alias)'
|
|
64
|
+
'list:List tasks'
|
|
65
|
+
'ls:List tasks (alias)'
|
|
66
|
+
'pop:Start working on next task'
|
|
67
|
+
'p:Pop next task (alias)'
|
|
68
|
+
'done:Mark a task as done'
|
|
69
|
+
'd:Mark done (alias)'
|
|
70
|
+
'status:Change task status'
|
|
71
|
+
'move:Move a task in the queue'
|
|
72
|
+
'mv:Move task (alias)'
|
|
73
|
+
'mt:Move task to top'
|
|
74
|
+
'mb:Move task to bottom'
|
|
75
|
+
'archive:Archive a task'
|
|
76
|
+
'ar:Archive task (alias)'
|
|
77
|
+
'purge:Purge archived tasks'
|
|
78
|
+
'edit:Edit a task'
|
|
79
|
+
'e:Edit task (alias)'
|
|
80
|
+
'show:Display full task info'
|
|
81
|
+
's:Show task (alias)'
|
|
82
|
+
'completion:Generate shell completions'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_arguments -C \\
|
|
86
|
+
'1: :->command' \\
|
|
87
|
+
'*: :->args'
|
|
88
|
+
|
|
89
|
+
case $state in
|
|
90
|
+
command)
|
|
91
|
+
_describe 'command' commands
|
|
92
|
+
;;
|
|
93
|
+
args)
|
|
94
|
+
case $words[2] in
|
|
95
|
+
done|d|move|mv|mt|mb|archive|ar|edit|e|show|s)
|
|
96
|
+
local -a task_ids
|
|
97
|
+
task_ids=(\${(f)"$(chop completion --list-ids 2>/dev/null)"})
|
|
98
|
+
_describe 'task id' task_ids
|
|
99
|
+
;;
|
|
100
|
+
esac
|
|
101
|
+
;;
|
|
102
|
+
esac
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
compdef _chop chop
|
|
106
|
+
compdef _chop ch
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Generate fish completion script
|
|
111
|
+
function generateFishCompletion(): string {
|
|
112
|
+
return `# Fish completion for chop
|
|
113
|
+
# Add this to your ~/.config/fish/completions/chop.fish:
|
|
114
|
+
# chop completion fish > ~/.config/fish/completions/chop.fish
|
|
115
|
+
# or: ch completion fish > ~/.config/fish/completions/ch.fish
|
|
116
|
+
|
|
117
|
+
# Disable file completion by default
|
|
118
|
+
complete -c chop -f
|
|
119
|
+
complete -c ch -f
|
|
120
|
+
|
|
121
|
+
# All commands including aliases
|
|
122
|
+
set -l all_cmds init add a list ls pop p done d status move mv mt mb archive ar purge edit e show s completion
|
|
123
|
+
|
|
124
|
+
# Commands for chop
|
|
125
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a init -d "Initialize chop in current directory"
|
|
126
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a add -d "Add a new task"
|
|
127
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a a -d "Add a new task (alias)"
|
|
128
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a list -d "List tasks"
|
|
129
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a ls -d "List tasks (alias)"
|
|
130
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a pop -d "Start working on next task"
|
|
131
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a p -d "Pop next task (alias)"
|
|
132
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a done -d "Mark a task as done"
|
|
133
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a d -d "Mark done (alias)"
|
|
134
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a status -d "Change task status"
|
|
135
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a move -d "Move a task in the queue"
|
|
136
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a mv -d "Move task (alias)"
|
|
137
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a mt -d "Move task to top"
|
|
138
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a mb -d "Move task to bottom"
|
|
139
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a archive -d "Archive a task"
|
|
140
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a ar -d "Archive task (alias)"
|
|
141
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a purge -d "Purge archived tasks"
|
|
142
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a edit -d "Edit a task"
|
|
143
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a e -d "Edit task (alias)"
|
|
144
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a show -d "Display full task info"
|
|
145
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a s -d "Show task (alias)"
|
|
146
|
+
complete -c chop -n "not __fish_seen_subcommand_from $all_cmds" -a completion -d "Generate shell completions"
|
|
147
|
+
|
|
148
|
+
# Task ID completion for commands that need it
|
|
149
|
+
complete -c chop -n "__fish_seen_subcommand_from done d move mv mt mb archive ar edit e show s" -a "(chop completion --list-ids 2>/dev/null)"
|
|
150
|
+
|
|
151
|
+
# Same for ch alias
|
|
152
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a init -d "Initialize chop in current directory"
|
|
153
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a add -d "Add a new task"
|
|
154
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a a -d "Add a new task (alias)"
|
|
155
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a list -d "List tasks"
|
|
156
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a ls -d "List tasks (alias)"
|
|
157
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a pop -d "Start working on next task"
|
|
158
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a p -d "Pop next task (alias)"
|
|
159
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a done -d "Mark a task as done"
|
|
160
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a d -d "Mark done (alias)"
|
|
161
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a status -d "Change task status"
|
|
162
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a move -d "Move a task in the queue"
|
|
163
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a mv -d "Move task (alias)"
|
|
164
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a mt -d "Move task to top"
|
|
165
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a mb -d "Move task to bottom"
|
|
166
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a archive -d "Archive a task"
|
|
167
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a ar -d "Archive task (alias)"
|
|
168
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a purge -d "Purge archived tasks"
|
|
169
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a edit -d "Edit a task"
|
|
170
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a e -d "Edit task (alias)"
|
|
171
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a show -d "Display full task info"
|
|
172
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a s -d "Show task (alias)"
|
|
173
|
+
complete -c ch -n "not __fish_seen_subcommand_from $all_cmds" -a completion -d "Generate shell completions"
|
|
174
|
+
|
|
175
|
+
complete -c ch -n "__fish_seen_subcommand_from done d move mv mt mb archive ar edit e show s" -a "(ch completion --list-ids 2>/dev/null)"
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Get list of task IDs for completion
|
|
180
|
+
async function getTaskIds(): Promise<string[]> {
|
|
181
|
+
try {
|
|
182
|
+
const store = await TaskStore.create();
|
|
183
|
+
const data = await store.readTasks();
|
|
184
|
+
return data.tasks.map((task) => task.id);
|
|
185
|
+
} catch {
|
|
186
|
+
// Return empty array if we can't read tasks (e.g., not initialized)
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function registerCompletionCommand(program: Command): void {
|
|
192
|
+
program
|
|
193
|
+
.command("completion [shell]")
|
|
194
|
+
.description("Generate shell completion script")
|
|
195
|
+
.option("--list-ids", "List task IDs (internal use)")
|
|
196
|
+
.action(async (shell: string | undefined, options: { listIds?: boolean }) => {
|
|
197
|
+
// Internal option for listing task IDs during completion
|
|
198
|
+
if (options.listIds) {
|
|
199
|
+
const ids = await getTaskIds();
|
|
200
|
+
console.log(ids.join("\n"));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Determine shell type
|
|
205
|
+
const targetShell = shell || detectShell();
|
|
206
|
+
|
|
207
|
+
switch (targetShell) {
|
|
208
|
+
case "bash":
|
|
209
|
+
console.log(generateBashCompletion());
|
|
210
|
+
break;
|
|
211
|
+
case "zsh":
|
|
212
|
+
console.log(generateZshCompletion());
|
|
213
|
+
break;
|
|
214
|
+
case "fish":
|
|
215
|
+
console.log(generateFishCompletion());
|
|
216
|
+
break;
|
|
217
|
+
default:
|
|
218
|
+
console.error(`Unknown shell: ${targetShell}`);
|
|
219
|
+
console.error("Supported shells: bash, zsh, fish");
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Detect current shell from environment
|
|
226
|
+
function detectShell(): string {
|
|
227
|
+
const shell = process.env.SHELL || "";
|
|
228
|
+
if (shell.includes("zsh")) return "zsh";
|
|
229
|
+
if (shell.includes("fish")) return "fish";
|
|
230
|
+
if (shell.includes("bash")) return "bash";
|
|
231
|
+
return "bash"; // Default to bash
|
|
232
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { TaskStore } from "../storage/task-store.ts";
|
|
3
|
+
import { findTaskById } from "../models/task.ts";
|
|
4
|
+
import { TaskNotFoundError } from "../errors.ts";
|
|
5
|
+
|
|
6
|
+
export function registerDoneCommand(program: Command): void {
|
|
7
|
+
program
|
|
8
|
+
.command("done <id>")
|
|
9
|
+
.alias("d")
|
|
10
|
+
.description("Mark a task as done")
|
|
11
|
+
.action(async (id: string) => {
|
|
12
|
+
try {
|
|
13
|
+
const store = await TaskStore.create();
|
|
14
|
+
|
|
15
|
+
await store.atomicUpdate((data) => {
|
|
16
|
+
const task = findTaskById(id, data.tasks);
|
|
17
|
+
|
|
18
|
+
if (!task) {
|
|
19
|
+
throw new TaskNotFoundError(id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
task.status = "done";
|
|
23
|
+
task.updatedAt = new Date().toISOString();
|
|
24
|
+
|
|
25
|
+
return { data, result: task };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
console.log(`Marked task ${id} as done`);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error instanceof Error) {
|
|
31
|
+
console.error(error.message);
|
|
32
|
+
} else {
|
|
33
|
+
console.error("An unexpected error occurred");
|
|
34
|
+
}
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { unlinkSync } from "node:fs";
|
|
5
|
+
import { TaskStore } from "../storage/task-store.ts";
|
|
6
|
+
import { findTaskById, isValidStatus } from "../models/task.ts";
|
|
7
|
+
import { TaskNotFoundError, ChopError, InvalidStatusError } from "../errors.ts";
|
|
8
|
+
import type { Task, TaskStatus, TaskEditData } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
// Convert a task to YAML-like format for editing
|
|
11
|
+
function taskToYaml(task: Task): string {
|
|
12
|
+
const lines: string[] = [];
|
|
13
|
+
|
|
14
|
+
lines.push(`title: ${task.title}`);
|
|
15
|
+
|
|
16
|
+
if (task.description) {
|
|
17
|
+
lines.push("description: |");
|
|
18
|
+
for (const line of task.description.split("\n")) {
|
|
19
|
+
lines.push(` ${line}`);
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
lines.push("description:");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
lines.push(`status: ${task.status}`);
|
|
26
|
+
|
|
27
|
+
if (task.dependsOn.length > 0) {
|
|
28
|
+
lines.push("depends_on:");
|
|
29
|
+
for (const dep of task.dependsOn) {
|
|
30
|
+
lines.push(` - ${dep}`);
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
lines.push("depends_on:");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return lines.join("\n") + "\n";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parse YAML-like format back to task edit data
|
|
40
|
+
function yamlToTaskEdit(content: string): TaskEditData {
|
|
41
|
+
const lines = content.split("\n");
|
|
42
|
+
const result: TaskEditData = {
|
|
43
|
+
title: "",
|
|
44
|
+
status: "open",
|
|
45
|
+
depends_on: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let inDescription = false;
|
|
49
|
+
let descriptionLines: string[] = [];
|
|
50
|
+
let inDependsOn = false;
|
|
51
|
+
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
// Check for field starts
|
|
54
|
+
if (line.startsWith("title:")) {
|
|
55
|
+
inDescription = false;
|
|
56
|
+
inDependsOn = false;
|
|
57
|
+
result.title = line.slice("title:".length).trim();
|
|
58
|
+
} else if (line.startsWith("description:")) {
|
|
59
|
+
inDescription = true;
|
|
60
|
+
inDependsOn = false;
|
|
61
|
+
descriptionLines = [];
|
|
62
|
+
const inline = line.slice("description:".length).trim();
|
|
63
|
+
if (inline && inline !== "|") {
|
|
64
|
+
descriptionLines.push(inline);
|
|
65
|
+
inDescription = false;
|
|
66
|
+
}
|
|
67
|
+
} else if (line.startsWith("status:")) {
|
|
68
|
+
inDescription = false;
|
|
69
|
+
inDependsOn = false;
|
|
70
|
+
result.status = line.slice("status:".length).trim() as TaskStatus;
|
|
71
|
+
} else if (line.startsWith("depends_on:")) {
|
|
72
|
+
inDescription = false;
|
|
73
|
+
inDependsOn = true;
|
|
74
|
+
result.depends_on = [];
|
|
75
|
+
} else if (inDescription && (line.startsWith(" ") || line === "")) {
|
|
76
|
+
// Description continuation (indented lines)
|
|
77
|
+
if (line.startsWith(" ")) {
|
|
78
|
+
descriptionLines.push(line.slice(2));
|
|
79
|
+
} else if (line === "" && descriptionLines.length > 0) {
|
|
80
|
+
descriptionLines.push("");
|
|
81
|
+
}
|
|
82
|
+
} else if (inDependsOn && line.startsWith(" - ")) {
|
|
83
|
+
// Depends on list item
|
|
84
|
+
result.depends_on.push(line.slice(4).trim());
|
|
85
|
+
} else if (line.trim() && !line.startsWith(" ")) {
|
|
86
|
+
// New field - end description
|
|
87
|
+
inDescription = false;
|
|
88
|
+
inDependsOn = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Set description if we collected any lines
|
|
93
|
+
if (descriptionLines.length > 0) {
|
|
94
|
+
// Trim trailing empty lines
|
|
95
|
+
while (descriptionLines.length > 0 && descriptionLines[descriptionLines.length - 1] === "") {
|
|
96
|
+
descriptionLines.pop();
|
|
97
|
+
}
|
|
98
|
+
result.description = descriptionLines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface EditOptions {
|
|
105
|
+
title?: string;
|
|
106
|
+
desc?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function registerEditCommand(program: Command): void {
|
|
110
|
+
program
|
|
111
|
+
.command("edit <id>")
|
|
112
|
+
.alias("e")
|
|
113
|
+
.description("Edit a task in your default editor")
|
|
114
|
+
.option("-t, --title <title>", "Set task title directly")
|
|
115
|
+
.option("-d, --desc <description>", "Set task description directly")
|
|
116
|
+
.action(async (id: string, options: EditOptions) => {
|
|
117
|
+
try {
|
|
118
|
+
const store = await TaskStore.create();
|
|
119
|
+
|
|
120
|
+
// Inline edit mode: if --title or --desc provided, update directly
|
|
121
|
+
if (options.title !== undefined || options.desc !== undefined) {
|
|
122
|
+
// Validate title is not empty if provided
|
|
123
|
+
if (options.title !== undefined && options.title.trim() === "") {
|
|
124
|
+
throw new ChopError("Title cannot be empty");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const updatedTask = await store.atomicUpdate((data) => {
|
|
128
|
+
const task = findTaskById(id, data.tasks);
|
|
129
|
+
if (!task) {
|
|
130
|
+
throw new TaskNotFoundError(id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (options.title !== undefined) {
|
|
134
|
+
task.title = options.title.trim();
|
|
135
|
+
}
|
|
136
|
+
if (options.desc !== undefined) {
|
|
137
|
+
task.description = options.desc.trim() || undefined;
|
|
138
|
+
}
|
|
139
|
+
task.updatedAt = new Date().toISOString();
|
|
140
|
+
|
|
141
|
+
return { data, result: task };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
console.log(`Updated task ${updatedTask.id}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Editor mode: open task in external editor
|
|
149
|
+
const data = await store.readTasks();
|
|
150
|
+
|
|
151
|
+
// Find the task
|
|
152
|
+
const task = findTaskById(id, data.tasks);
|
|
153
|
+
if (!task) {
|
|
154
|
+
throw new TaskNotFoundError(id);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Create temp file with YAML content
|
|
158
|
+
const tempPath = join(tmpdir(), `chop-edit-${task.id}.yaml`);
|
|
159
|
+
const originalContent = taskToYaml(task);
|
|
160
|
+
await Bun.write(tempPath, originalContent);
|
|
161
|
+
|
|
162
|
+
// Get editor from environment and parse into command + args
|
|
163
|
+
const editorEnv = process.env.EDITOR || process.env.VISUAL || "vi";
|
|
164
|
+
const editorParts = editorEnv.split(/\s+/).filter(Boolean);
|
|
165
|
+
const editorCmd = editorParts[0] || "vi";
|
|
166
|
+
const editorArgs = [...editorParts.slice(1), tempPath];
|
|
167
|
+
|
|
168
|
+
// Open editor
|
|
169
|
+
const proc = Bun.spawn([editorCmd, ...editorArgs], {
|
|
170
|
+
stdin: "inherit",
|
|
171
|
+
stdout: "inherit",
|
|
172
|
+
stderr: "inherit",
|
|
173
|
+
});
|
|
174
|
+
await proc.exited;
|
|
175
|
+
|
|
176
|
+
// Read edited content
|
|
177
|
+
const editedContent = await Bun.file(tempPath).text();
|
|
178
|
+
|
|
179
|
+
// Check if content was changed
|
|
180
|
+
if (editedContent.trim() === "" || editedContent === originalContent) {
|
|
181
|
+
console.log("No changes made. Edit cancelled.");
|
|
182
|
+
// Clean up temp file
|
|
183
|
+
unlinkSync(tempPath);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Parse edited content
|
|
188
|
+
const editData = yamlToTaskEdit(editedContent);
|
|
189
|
+
|
|
190
|
+
// Validate
|
|
191
|
+
if (!editData.title) {
|
|
192
|
+
throw new ChopError("Title cannot be empty");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!isValidStatus(editData.status)) {
|
|
196
|
+
throw new InvalidStatusError(editData.status);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Apply changes
|
|
200
|
+
await store.atomicUpdate((data) => {
|
|
201
|
+
const task = findTaskById(id, data.tasks);
|
|
202
|
+
if (!task) {
|
|
203
|
+
throw new TaskNotFoundError(id);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
task.title = editData.title;
|
|
207
|
+
task.description = editData.description;
|
|
208
|
+
task.status = editData.status;
|
|
209
|
+
task.dependsOn = editData.depends_on;
|
|
210
|
+
task.updatedAt = new Date().toISOString();
|
|
211
|
+
|
|
212
|
+
return { data, result: task };
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
console.log(`Updated task ${task.id}`);
|
|
216
|
+
|
|
217
|
+
// Clean up temp file
|
|
218
|
+
unlinkSync(tempPath);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error instanceof Error) {
|
|
221
|
+
console.error(error.message);
|
|
222
|
+
} else {
|
|
223
|
+
console.error("An unexpected error occurred");
|
|
224
|
+
}
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { AlreadyInitializedError } from "../errors.ts";
|
|
3
|
+
import { isInitialized } from "../storage/storage-resolver.ts";
|
|
4
|
+
import { TaskStore } from "../storage/task-store.ts";
|
|
5
|
+
import type { StorageLocation } from "../types.ts";
|
|
6
|
+
import { getGitRepoRoot } from "../utils/git.ts";
|
|
7
|
+
import { confirm, select } from "../utils/prompts.ts";
|
|
8
|
+
|
|
9
|
+
export function registerInitCommand(program: Command): void {
|
|
10
|
+
program
|
|
11
|
+
.command("init")
|
|
12
|
+
.description("Initialize chop for the current repository")
|
|
13
|
+
.action(async () => {
|
|
14
|
+
try {
|
|
15
|
+
// Check if already initialized
|
|
16
|
+
if (await isInitialized()) {
|
|
17
|
+
throw new AlreadyInitializedError();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Prompt for storage location
|
|
21
|
+
const location = await select<StorageLocation>(
|
|
22
|
+
"Where would you like to store tasks?",
|
|
23
|
+
[
|
|
24
|
+
{
|
|
25
|
+
value: "local",
|
|
26
|
+
label: "Local (in .chop/ directory in this repo)",
|
|
27
|
+
},
|
|
28
|
+
{ value: "global", label: "Global (~/.local/share/chop/)" },
|
|
29
|
+
]
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Initialize storage
|
|
33
|
+
await TaskStore.initialize(location);
|
|
34
|
+
|
|
35
|
+
// If local, ask about gitignore
|
|
36
|
+
if (location === "local") {
|
|
37
|
+
const addToGitignore = await confirm(
|
|
38
|
+
"Add .chop/ to .gitignore? (Choose no to share tasks with your team)",
|
|
39
|
+
true
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (addToGitignore) {
|
|
43
|
+
const repoRoot = await getGitRepoRoot();
|
|
44
|
+
const gitignorePath = `${repoRoot}/.gitignore`;
|
|
45
|
+
const gitignoreFile = Bun.file(gitignorePath);
|
|
46
|
+
|
|
47
|
+
let content = "";
|
|
48
|
+
if (await gitignoreFile.exists()) {
|
|
49
|
+
content = await gitignoreFile.text();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check if .chop is already in gitignore
|
|
53
|
+
if (!content.includes(".chop")) {
|
|
54
|
+
const newContent =
|
|
55
|
+
content + (content.endsWith("\n") ? "" : "\n") + ".chop/\n";
|
|
56
|
+
await Bun.write(gitignorePath, newContent);
|
|
57
|
+
console.log("Added .chop/ to .gitignore");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(`Initialized chop with ${location} storage.`);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof Error) {
|
|
65
|
+
console.error(error.message);
|
|
66
|
+
} else {
|
|
67
|
+
console.error("An unexpected error occurred");
|
|
68
|
+
}
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { TaskStore } from "../storage/task-store.ts";
|
|
3
|
+
import { formatTaskTable } from "../utils/display.ts";
|
|
4
|
+
import type { Task } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
export function registerListCommand(program: Command): void {
|
|
7
|
+
program
|
|
8
|
+
.command("list")
|
|
9
|
+
.alias("ls")
|
|
10
|
+
.description("List tasks")
|
|
11
|
+
.option("-o, --open", "Show only open and draft tasks (default)")
|
|
12
|
+
.option("-p, --progress", "Show only in-progress tasks")
|
|
13
|
+
.option("--done", "Show only done tasks")
|
|
14
|
+
.option("-a, --all", "Show all non-archived tasks")
|
|
15
|
+
.action(async (options) => {
|
|
16
|
+
try {
|
|
17
|
+
const store = await TaskStore.create();
|
|
18
|
+
const data = await store.readTasks();
|
|
19
|
+
|
|
20
|
+
let filteredTasks: Task[];
|
|
21
|
+
|
|
22
|
+
if (options.all) {
|
|
23
|
+
// All non-archived tasks
|
|
24
|
+
filteredTasks = data.tasks.filter((t) => t.status !== "archived");
|
|
25
|
+
} else if (options.progress) {
|
|
26
|
+
// Only in-progress
|
|
27
|
+
filteredTasks = data.tasks.filter((t) => t.status === "in-progress");
|
|
28
|
+
} else if (options.done) {
|
|
29
|
+
// Only done
|
|
30
|
+
filteredTasks = data.tasks.filter((t) => t.status === "done");
|
|
31
|
+
} else {
|
|
32
|
+
// Default: open and draft
|
|
33
|
+
filteredTasks = data.tasks.filter(
|
|
34
|
+
(t) => t.status === "open" || t.status === "draft"
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(formatTaskTable(filteredTasks, data.tasks));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof Error) {
|
|
41
|
+
console.error(error.message);
|
|
42
|
+
} else {
|
|
43
|
+
console.error("An unexpected error occurred");
|
|
44
|
+
}
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|