trak_flow 0.1.3
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.
- checksums.yaml +7 -0
- data/.envrc +3 -0
- data/CHANGELOG.md +69 -0
- data/COMMITS.md +196 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +281 -0
- data/README.md +479 -0
- data/Rakefile +16 -0
- data/bin/tf +6 -0
- data/bin/tf_mcp +81 -0
- data/docs/.keep +0 -0
- data/docs/api/database.md +434 -0
- data/docs/api/ruby-library.md +349 -0
- data/docs/api/task-model.md +341 -0
- data/docs/assets/stylesheets/extra.css +53 -0
- data/docs/assets/trak_flow.jpg +0 -0
- data/docs/cli/admin-commands.md +369 -0
- data/docs/cli/dependency-commands.md +321 -0
- data/docs/cli/label-commands.md +222 -0
- data/docs/cli/overview.md +163 -0
- data/docs/cli/plan-commands.md +344 -0
- data/docs/cli/task-commands.md +333 -0
- data/docs/core-concepts/dependencies.md +232 -0
- data/docs/core-concepts/labels.md +217 -0
- data/docs/core-concepts/overview.md +178 -0
- data/docs/core-concepts/plans-workflows.md +264 -0
- data/docs/core-concepts/tasks.md +205 -0
- data/docs/getting-started/configuration.md +120 -0
- data/docs/getting-started/installation.md +79 -0
- data/docs/getting-started/quick-start.md +245 -0
- data/docs/index.md +169 -0
- data/docs/mcp/integration.md +302 -0
- data/docs/mcp/overview.md +206 -0
- data/docs/mcp/resources.md +284 -0
- data/docs/mcp/tools.md +457 -0
- data/examples/basic_usage.rb +365 -0
- data/examples/cli_demo.sh +314 -0
- data/examples/mcp/Gemfile +9 -0
- data/examples/mcp/Gemfile.lock +226 -0
- data/examples/mcp/http_demo.rb +232 -0
- data/examples/mcp/stdio_demo.rb +146 -0
- data/lib/trak_flow/cli/admin_commands.rb +136 -0
- data/lib/trak_flow/cli/config_commands.rb +260 -0
- data/lib/trak_flow/cli/dep_commands.rb +71 -0
- data/lib/trak_flow/cli/label_commands.rb +76 -0
- data/lib/trak_flow/cli/main_commands.rb +386 -0
- data/lib/trak_flow/cli/plan_commands.rb +185 -0
- data/lib/trak_flow/cli/workflow_commands.rb +133 -0
- data/lib/trak_flow/cli.rb +110 -0
- data/lib/trak_flow/config/defaults.yml +114 -0
- data/lib/trak_flow/config/section.rb +74 -0
- data/lib/trak_flow/config.rb +276 -0
- data/lib/trak_flow/graph/dependency_graph.rb +288 -0
- data/lib/trak_flow/id_generator.rb +52 -0
- data/lib/trak_flow/mcp/resources/base_resource.rb +25 -0
- data/lib/trak_flow/mcp/resources/dependency_graph.rb +31 -0
- data/lib/trak_flow/mcp/resources/label_list.rb +21 -0
- data/lib/trak_flow/mcp/resources/plan_by_id.rb +27 -0
- data/lib/trak_flow/mcp/resources/plan_list.rb +21 -0
- data/lib/trak_flow/mcp/resources/task_by_id.rb +31 -0
- data/lib/trak_flow/mcp/resources/task_list.rb +21 -0
- data/lib/trak_flow/mcp/resources/task_next.rb +30 -0
- data/lib/trak_flow/mcp/resources/workflow_by_id.rb +27 -0
- data/lib/trak_flow/mcp/resources/workflow_list.rb +21 -0
- data/lib/trak_flow/mcp/server.rb +140 -0
- data/lib/trak_flow/mcp/tools/base_tool.rb +29 -0
- data/lib/trak_flow/mcp/tools/comment_add.rb +33 -0
- data/lib/trak_flow/mcp/tools/dep_add.rb +34 -0
- data/lib/trak_flow/mcp/tools/dep_remove.rb +25 -0
- data/lib/trak_flow/mcp/tools/label_add.rb +28 -0
- data/lib/trak_flow/mcp/tools/label_remove.rb +25 -0
- data/lib/trak_flow/mcp/tools/plan_add_step.rb +35 -0
- data/lib/trak_flow/mcp/tools/plan_create.rb +33 -0
- data/lib/trak_flow/mcp/tools/plan_run.rb +58 -0
- data/lib/trak_flow/mcp/tools/plan_start.rb +58 -0
- data/lib/trak_flow/mcp/tools/task_block.rb +27 -0
- data/lib/trak_flow/mcp/tools/task_close.rb +26 -0
- data/lib/trak_flow/mcp/tools/task_create.rb +51 -0
- data/lib/trak_flow/mcp/tools/task_defer.rb +27 -0
- data/lib/trak_flow/mcp/tools/task_start.rb +25 -0
- data/lib/trak_flow/mcp/tools/task_update.rb +36 -0
- data/lib/trak_flow/mcp/tools/workflow_discard.rb +28 -0
- data/lib/trak_flow/mcp/tools/workflow_summarize.rb +34 -0
- data/lib/trak_flow/mcp.rb +38 -0
- data/lib/trak_flow/models/comment.rb +71 -0
- data/lib/trak_flow/models/dependency.rb +96 -0
- data/lib/trak_flow/models/label.rb +90 -0
- data/lib/trak_flow/models/task.rb +188 -0
- data/lib/trak_flow/storage/database.rb +638 -0
- data/lib/trak_flow/storage/jsonl.rb +259 -0
- data/lib/trak_flow/time_parser.rb +15 -0
- data/lib/trak_flow/version.rb +5 -0
- data/lib/trak_flow.rb +100 -0
- data/mkdocs.yml +143 -0
- metadata +392 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# Admin subcommands
|
|
6
|
+
class AdminCommands < Thor
|
|
7
|
+
class_option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
8
|
+
|
|
9
|
+
desc "cleanup", "Clean up old closed tasks"
|
|
10
|
+
option :older_than, type: :numeric, default: 30, desc: "Days since closed"
|
|
11
|
+
option :dry_run, type: :boolean, default: false, desc: "Show what would be deleted"
|
|
12
|
+
option :force, type: :boolean, default: false, desc: "Skip confirmation"
|
|
13
|
+
option :cascade, type: :boolean, default: false, desc: "Also delete children"
|
|
14
|
+
def cleanup
|
|
15
|
+
with_database do |db|
|
|
16
|
+
cutoff = Time.now.utc - (options[:older_than] * 24 * 60 * 60)
|
|
17
|
+
candidates = db.list_tasks(status: "closed", include_tombstones: true)
|
|
18
|
+
.select { |i| i.closed_at && i.closed_at < cutoff }
|
|
19
|
+
|
|
20
|
+
if candidates.empty?
|
|
21
|
+
puts "No tasks to clean up"
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if options[:dry_run]
|
|
26
|
+
puts "Would delete #{candidates.size} task(s):"
|
|
27
|
+
candidates.each { |i| puts " #{i.id}: #{i.title}" }
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless options[:force]
|
|
32
|
+
puts "About to delete #{candidates.size} task(s). Continue? (y/n)"
|
|
33
|
+
return unless $stdin.gets.strip.downcase == "y"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
candidates.each do |task|
|
|
37
|
+
db.child_tasks(task.id).each { |c| db.delete_task(c.id) } if options[:cascade]
|
|
38
|
+
db.delete_task(task.id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
output({ deleted: candidates.size }) do
|
|
42
|
+
puts "Deleted #{candidates.size} task(s)"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
desc "compact", "Compact the database"
|
|
48
|
+
option :analyze, type: :boolean, default: false, desc: "Show compaction stats"
|
|
49
|
+
option :apply, type: :boolean, default: false, desc: "Apply compaction"
|
|
50
|
+
def compact
|
|
51
|
+
with_database do |db|
|
|
52
|
+
stats = {
|
|
53
|
+
total_tasks: db.all_task_ids.size,
|
|
54
|
+
closed_tasks: db.list_tasks(status: "closed", include_tombstones: true).size,
|
|
55
|
+
ephemeral: db.find_ephemeral_workflows.size,
|
|
56
|
+
plans: db.find_plans.size,
|
|
57
|
+
workflows: db.find_workflows.size
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if options[:analyze]
|
|
61
|
+
output(stats) do
|
|
62
|
+
stats.each { |k, v| puts "#{k}: #{v}" }
|
|
63
|
+
end
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if options[:apply]
|
|
68
|
+
db.list_tasks(status: "closed").each do |task|
|
|
69
|
+
next unless task.closed_at && task.closed_at < (Time.now.utc - 30 * 24 * 60 * 60)
|
|
70
|
+
|
|
71
|
+
task.status = "tombstone"
|
|
72
|
+
db.update_task(task)
|
|
73
|
+
end
|
|
74
|
+
puts "Compaction complete"
|
|
75
|
+
else
|
|
76
|
+
puts "Use --analyze to see stats or --apply to compact"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
desc "graph", "Generate dependency graph"
|
|
82
|
+
option :format, type: :string, default: "dot", desc: "Output format (dot, svg)"
|
|
83
|
+
option :output, aliases: "-o", type: :string, desc: "Output file"
|
|
84
|
+
option :include_closed, type: :boolean, default: false, desc: "Include closed tasks"
|
|
85
|
+
def graph
|
|
86
|
+
with_database do |db|
|
|
87
|
+
dep_graph = Graph::DependencyGraph.new(db)
|
|
88
|
+
|
|
89
|
+
graph_output = case options[:format]
|
|
90
|
+
when "svg" then dep_graph.to_svg(include_closed: options[:include_closed])
|
|
91
|
+
else dep_graph.to_dot(include_closed: options[:include_closed])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if options[:output]
|
|
95
|
+
File.write(options[:output], graph_output)
|
|
96
|
+
puts "Graph written to #{options[:output]}"
|
|
97
|
+
else
|
|
98
|
+
puts graph_output
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
desc "analyze", "Analyze the task graph"
|
|
104
|
+
def analyze
|
|
105
|
+
with_database do |db|
|
|
106
|
+
dep_graph = Graph::DependencyGraph.new(db)
|
|
107
|
+
analysis = dep_graph.analyze
|
|
108
|
+
|
|
109
|
+
output(analysis) do
|
|
110
|
+
analysis.each do |k, v|
|
|
111
|
+
if v.is_a?(Array)
|
|
112
|
+
puts "#{k}:"
|
|
113
|
+
v.each { |item| puts " - #{item}" }
|
|
114
|
+
else
|
|
115
|
+
puts "#{k}: #{v}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# Delegate helper methods to parent CLI
|
|
125
|
+
def with_database(&block) = CLI.new.with_database(&block)
|
|
126
|
+
|
|
127
|
+
def output(json_data, &human_block)
|
|
128
|
+
if options[:json]
|
|
129
|
+
puts Oj.dump(json_data, mode: :compat, indent: 2)
|
|
130
|
+
else
|
|
131
|
+
human_block.call
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# Config subcommands
|
|
6
|
+
class ConfigCommands < Thor
|
|
7
|
+
class_option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
8
|
+
|
|
9
|
+
XDG_CONFIG_PATH = File.expand_path("~/.config/trak_flow/trak_flow.yml").freeze
|
|
10
|
+
PROJECT_CONFIG_PATH = ".trak_flow/config.yml"
|
|
11
|
+
|
|
12
|
+
desc "show", "Show the current configuration file"
|
|
13
|
+
def show
|
|
14
|
+
active_path = find_active_config_path
|
|
15
|
+
|
|
16
|
+
if active_path.nil?
|
|
17
|
+
output({ error: "No config file found", paths: { xdg: XDG_CONFIG_PATH, project: PROJECT_CONFIG_PATH } }) do
|
|
18
|
+
puts pastel.yellow("No configuration file found.")
|
|
19
|
+
puts "Create one with: tf config reset"
|
|
20
|
+
puts ""
|
|
21
|
+
puts "Expected locations:"
|
|
22
|
+
puts " XDG: #{XDG_CONFIG_PATH}"
|
|
23
|
+
puts " Project: #{File.expand_path(PROJECT_CONFIG_PATH)}"
|
|
24
|
+
end
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
config_content = File.read(active_path)
|
|
29
|
+
|
|
30
|
+
if options[:json]
|
|
31
|
+
yaml_data = YAML.safe_load(config_content, permitted_classes: [Symbol], symbolize_names: true)
|
|
32
|
+
puts Oj.dump({ path: active_path, config: yaml_data }, mode: :compat, indent: 2)
|
|
33
|
+
else
|
|
34
|
+
puts pastel.bold("# #{active_path}")
|
|
35
|
+
puts config_content
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "defaults", "Show the bundled default configuration"
|
|
40
|
+
def defaults
|
|
41
|
+
defaults_content = File.read(Config::DEFAULTS_PATH)
|
|
42
|
+
|
|
43
|
+
if options[:json]
|
|
44
|
+
yaml_data = YAML.safe_load(defaults_content, permitted_classes: [Symbol], symbolize_names: true)
|
|
45
|
+
puts Oj.dump(yaml_data, mode: :compat, indent: 2)
|
|
46
|
+
else
|
|
47
|
+
puts pastel.bold("# TrakFlow Bundled Defaults")
|
|
48
|
+
puts pastel.dim("# #{Config::DEFAULTS_PATH}")
|
|
49
|
+
puts ""
|
|
50
|
+
puts defaults_content
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Show defaults when no subcommand is given
|
|
55
|
+
default_task :defaults
|
|
56
|
+
|
|
57
|
+
desc "reset", "Reset configuration to defaults"
|
|
58
|
+
option :global, aliases: "-g", type: :boolean, default: false, desc: "Reset global (XDG) config"
|
|
59
|
+
option :force, aliases: "-f", type: :boolean, default: false, desc: "Overwrite existing config"
|
|
60
|
+
def reset
|
|
61
|
+
target_path = determine_config_path(options[:global])
|
|
62
|
+
target_dir = File.dirname(target_path)
|
|
63
|
+
|
|
64
|
+
if File.exist?(target_path) && !options[:force]
|
|
65
|
+
output({ success: false, error: "Config file already exists. Use --force to overwrite." }) do
|
|
66
|
+
puts pastel.red("Config file already exists at #{target_path}")
|
|
67
|
+
puts "Use --force to overwrite"
|
|
68
|
+
end
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
FileUtils.mkdir_p(target_dir)
|
|
73
|
+
FileUtils.cp(Config::DEFAULTS_PATH, target_path)
|
|
74
|
+
|
|
75
|
+
output({ success: true, path: target_path }) do
|
|
76
|
+
puts pastel.green("Configuration reset to defaults at #{target_path}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
desc "get KEY", "Get a configuration value (e.g., 'database.path', 'mcp.port')"
|
|
81
|
+
def get(key)
|
|
82
|
+
value = get_nested_value(key)
|
|
83
|
+
|
|
84
|
+
if value.nil?
|
|
85
|
+
output({ key: key, value: nil, error: "Key not found" }) do
|
|
86
|
+
puts pastel.red("Key '#{key}' not found in configuration")
|
|
87
|
+
end
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
output({ key: key, value: serialize_value(value) }) do
|
|
92
|
+
if value.is_a?(TrakFlow::ConfigSection)
|
|
93
|
+
puts value.to_h.to_yaml.lines[1..].join
|
|
94
|
+
else
|
|
95
|
+
puts value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
desc "set KEY VALUE", "Set a configuration value (e.g., 'database.path /path/to/db')"
|
|
101
|
+
def set(key, value)
|
|
102
|
+
config_path = find_writable_config_path
|
|
103
|
+
|
|
104
|
+
# Load existing config or create new
|
|
105
|
+
config_data = if File.exist?(config_path)
|
|
106
|
+
YAML.safe_load(File.read(config_path), permitted_classes: [Symbol], symbolize_names: true) || {}
|
|
107
|
+
else
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Ensure defaults section exists
|
|
112
|
+
config_data[:defaults] ||= {}
|
|
113
|
+
|
|
114
|
+
# Parse the value (convert to appropriate type)
|
|
115
|
+
parsed_value = parse_value(value)
|
|
116
|
+
|
|
117
|
+
# Set the nested value
|
|
118
|
+
set_nested_value(config_data[:defaults], key, parsed_value)
|
|
119
|
+
|
|
120
|
+
# Ensure directory exists
|
|
121
|
+
FileUtils.mkdir_p(File.dirname(config_path))
|
|
122
|
+
|
|
123
|
+
# Write the config file
|
|
124
|
+
File.write(config_path, config_data.to_yaml)
|
|
125
|
+
|
|
126
|
+
# Reset config to pick up new values
|
|
127
|
+
TrakFlow.reset_config!
|
|
128
|
+
|
|
129
|
+
output({ key: key, value: parsed_value, path: config_path }) do
|
|
130
|
+
puts pastel.green("Set #{key} = #{parsed_value}")
|
|
131
|
+
puts "Configuration saved to #{config_path}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
desc "path", "Show configuration file paths"
|
|
136
|
+
def path
|
|
137
|
+
project_path = File.expand_path(PROJECT_CONFIG_PATH)
|
|
138
|
+
project_exists = File.exist?(project_path)
|
|
139
|
+
xdg_exists = File.exist?(XDG_CONFIG_PATH)
|
|
140
|
+
|
|
141
|
+
paths = {
|
|
142
|
+
defaults: Config::DEFAULTS_PATH,
|
|
143
|
+
xdg: { path: XDG_CONFIG_PATH, exists: xdg_exists },
|
|
144
|
+
project: { path: project_path, exists: project_exists },
|
|
145
|
+
active: find_active_config_path
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
output(paths) do
|
|
149
|
+
puts "Configuration paths:"
|
|
150
|
+
puts " Defaults: #{Config::DEFAULTS_PATH}"
|
|
151
|
+
puts " XDG: #{XDG_CONFIG_PATH} #{xdg_exists ? pastel.green('(exists)') : pastel.dim('(not found)')}"
|
|
152
|
+
puts " Project: #{project_path} #{project_exists ? pastel.green('(exists)') : pastel.dim('(not found)')}"
|
|
153
|
+
puts ""
|
|
154
|
+
puts "Active config: #{find_active_config_path || pastel.dim('(defaults only)')}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def pastel
|
|
161
|
+
@pastel ||= Pastel.new
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def output(json_data, &human_block)
|
|
165
|
+
if options[:json]
|
|
166
|
+
puts Oj.dump(json_data, mode: :compat, indent: 2)
|
|
167
|
+
else
|
|
168
|
+
human_block.call
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def determine_config_path(global)
|
|
173
|
+
if global
|
|
174
|
+
XDG_CONFIG_PATH
|
|
175
|
+
elsif File.exist?(PROJECT_CONFIG_PATH)
|
|
176
|
+
File.expand_path(PROJECT_CONFIG_PATH)
|
|
177
|
+
elsif File.directory?(".trak_flow")
|
|
178
|
+
File.expand_path(PROJECT_CONFIG_PATH)
|
|
179
|
+
else
|
|
180
|
+
XDG_CONFIG_PATH
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def find_writable_config_path
|
|
185
|
+
# Prefer project config if .trak_flow exists, otherwise use XDG
|
|
186
|
+
if File.directory?(".trak_flow")
|
|
187
|
+
File.expand_path(PROJECT_CONFIG_PATH)
|
|
188
|
+
else
|
|
189
|
+
XDG_CONFIG_PATH
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def find_active_config_path
|
|
194
|
+
project_path = File.expand_path(PROJECT_CONFIG_PATH)
|
|
195
|
+
return project_path if File.exist?(project_path)
|
|
196
|
+
return XDG_CONFIG_PATH if File.exist?(XDG_CONFIG_PATH)
|
|
197
|
+
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def get_nested_value(key)
|
|
202
|
+
parts = key.split(".")
|
|
203
|
+
value = TrakFlow.config
|
|
204
|
+
|
|
205
|
+
parts.each do |part|
|
|
206
|
+
if value.respond_to?(part)
|
|
207
|
+
value = value.send(part)
|
|
208
|
+
elsif value.respond_to?(:[])
|
|
209
|
+
value = value[part.to_sym] || value[part]
|
|
210
|
+
else
|
|
211
|
+
return nil
|
|
212
|
+
end
|
|
213
|
+
return nil if value.nil?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
value
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def set_nested_value(hash, key, value)
|
|
220
|
+
parts = key.split(".")
|
|
221
|
+
current = hash
|
|
222
|
+
|
|
223
|
+
parts[0...-1].each do |part|
|
|
224
|
+
current[part.to_sym] ||= {}
|
|
225
|
+
current = current[part.to_sym]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
current[parts.last.to_sym] = value
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def parse_value(value)
|
|
232
|
+
# Try to parse as various types
|
|
233
|
+
case value.downcase
|
|
234
|
+
when "true" then true
|
|
235
|
+
when "false" then false
|
|
236
|
+
when "nil", "null" then nil
|
|
237
|
+
else
|
|
238
|
+
# Try integer
|
|
239
|
+
if value.match?(/\A-?\d+\z/)
|
|
240
|
+
value.to_i
|
|
241
|
+
# Try float
|
|
242
|
+
elsif value.match?(/\A-?\d+\.\d+\z/)
|
|
243
|
+
value.to_f
|
|
244
|
+
else
|
|
245
|
+
# Keep as string, expand ~ for paths
|
|
246
|
+
value.start_with?("~") ? File.expand_path(value) : value
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def serialize_value(value)
|
|
252
|
+
if value.is_a?(TrakFlow::ConfigSection)
|
|
253
|
+
value.to_h
|
|
254
|
+
else
|
|
255
|
+
value
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# Dependency subcommands
|
|
6
|
+
class DepCommands < Thor
|
|
7
|
+
class_option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
8
|
+
|
|
9
|
+
desc "add SOURCE TARGET", "Add a dependency"
|
|
10
|
+
option :type, aliases: "-t", type: :string, default: "blocks", desc: "Dependency type"
|
|
11
|
+
def add(source, target)
|
|
12
|
+
with_database do |db|
|
|
13
|
+
dep = Models::Dependency.new(
|
|
14
|
+
source_id: source,
|
|
15
|
+
target_id: target,
|
|
16
|
+
type: options[:type]
|
|
17
|
+
)
|
|
18
|
+
db.add_dependency(dep)
|
|
19
|
+
|
|
20
|
+
output(dep.to_h) do
|
|
21
|
+
puts "Added dependency: #{source} #{options[:type]} #{target}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
desc "remove SOURCE TARGET", "Remove a dependency"
|
|
27
|
+
option :type, aliases: "-t", type: :string, desc: "Dependency type"
|
|
28
|
+
def remove(source, target)
|
|
29
|
+
with_database do |db|
|
|
30
|
+
count = db.remove_dependency(source, target, type: options[:type])
|
|
31
|
+
|
|
32
|
+
output({ removed: count }) do
|
|
33
|
+
puts "Removed #{count} dependency(ies)"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "tree ID", "Show dependency tree"
|
|
39
|
+
def tree(id)
|
|
40
|
+
with_database do |db|
|
|
41
|
+
graph = Graph::DependencyGraph.new(db)
|
|
42
|
+
tree_data = graph.dependency_tree(id)
|
|
43
|
+
|
|
44
|
+
output(tree_data) do
|
|
45
|
+
print_tree(tree_data, 0)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def print_tree(node, depth)
|
|
53
|
+
indent = " " * depth
|
|
54
|
+
puts "#{indent}#{status_icon(node[:status])} #{node[:id]}: #{node[:title]}"
|
|
55
|
+
node[:children].each { |child| print_tree(child, depth + 1) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Delegate helper methods to parent CLI
|
|
59
|
+
def with_database(&block) = CLI.new.with_database(&block)
|
|
60
|
+
def status_icon(status) = CLI.new.status_icon(status)
|
|
61
|
+
|
|
62
|
+
def output(json_data, &human_block)
|
|
63
|
+
if options[:json]
|
|
64
|
+
puts Oj.dump(json_data, mode: :compat, indent: 2)
|
|
65
|
+
else
|
|
66
|
+
human_block.call
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# Label subcommands
|
|
6
|
+
class LabelCommands < Thor
|
|
7
|
+
class_option :json, aliases: "-j", type: :boolean, default: false, desc: "Output in JSON format"
|
|
8
|
+
|
|
9
|
+
desc "add ID LABEL", "Add a label to a task"
|
|
10
|
+
def add(id, label_name)
|
|
11
|
+
with_database do |db|
|
|
12
|
+
label = Models::Label.new(task_id: id, name: label_name)
|
|
13
|
+
db.add_label(label)
|
|
14
|
+
|
|
15
|
+
output(label.to_h) do
|
|
16
|
+
puts "Added label '#{label_name}' to #{id}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "remove ID LABEL", "Remove a label from a task"
|
|
22
|
+
def remove(id, label_name)
|
|
23
|
+
with_database do |db|
|
|
24
|
+
count = db.remove_label(id, label_name)
|
|
25
|
+
|
|
26
|
+
output({ removed: count }) do
|
|
27
|
+
puts count.positive? ? "Removed label '#{label_name}' from #{id}" : "Label not found"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "list ID", "List labels for a task"
|
|
33
|
+
def list(id)
|
|
34
|
+
with_database do |db|
|
|
35
|
+
labels = db.find_labels(id)
|
|
36
|
+
|
|
37
|
+
output(labels.map(&:to_h)) do
|
|
38
|
+
if labels.empty?
|
|
39
|
+
puts "No labels"
|
|
40
|
+
else
|
|
41
|
+
labels.each { |l| puts l.name }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
desc "list-all", "List all labels in the database"
|
|
48
|
+
def list_all
|
|
49
|
+
with_database do |db|
|
|
50
|
+
labels = db.all_labels
|
|
51
|
+
|
|
52
|
+
output(labels) do
|
|
53
|
+
if labels.empty?
|
|
54
|
+
puts "No labels"
|
|
55
|
+
else
|
|
56
|
+
labels.each { |l| puts l }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Delegate helper methods to parent CLI
|
|
65
|
+
def with_database(&block) = CLI.new.with_database(&block)
|
|
66
|
+
|
|
67
|
+
def output(json_data, &human_block)
|
|
68
|
+
if options[:json]
|
|
69
|
+
puts Oj.dump(json_data, mode: :compat, indent: 2)
|
|
70
|
+
else
|
|
71
|
+
human_block.call
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|