na 1.2.87 → 1.2.88

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.
data/lib/na/plugins.rb ADDED
@@ -0,0 +1,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require 'csv'
6
+
7
+ module NA
8
+ # Plugins module for NA
9
+ module Plugins
10
+ module_function
11
+
12
+ def plugins_home
13
+ File.expand_path('~/.local/share/na/plugins')
14
+ end
15
+
16
+ def ensure_plugins_home
17
+ dir = plugins_home
18
+ return if File.directory?(dir)
19
+
20
+ FileUtils.mkdir_p(dir)
21
+ readme = File.join(dir, 'README.md')
22
+ File.write(readme, default_readme_contents) unless File.exist?(readme)
23
+ create_sample_plugins(dir)
24
+ end
25
+
26
+ def list_plugins
27
+ dir = plugins_home
28
+ return {} unless File.directory?(dir)
29
+
30
+ Dir.children(dir).each_with_object({}) do |entry, acc|
31
+ path = File.join(dir, entry)
32
+ next unless File.file?(path)
33
+ next if entry =~ /\.(md|bak)$/i
34
+ next unless shebang?(path)
35
+
36
+ base = File.basename(entry, File.extname(entry))
37
+ key = base.gsub(/[\s_]/, '')
38
+ acc[key.downcase] = path
39
+ end
40
+ end
41
+
42
+ def resolve_plugin(name)
43
+ return nil unless name && !name.to_s.strip.empty?
44
+
45
+ normalized = name.to_s.strip.gsub(/[\s_]/, '').downcase
46
+ candidates = list_plugins
47
+ return candidates[normalized] if candidates.key?(normalized)
48
+
49
+ # Fallback: try exact filename match in dir
50
+ path = File.join(plugins_home, name)
51
+ File.file?(path) ? path : nil
52
+ end
53
+
54
+ def shebang_for(file)
55
+ first = begin
56
+ File.open(file, 'r', &:readline)
57
+ rescue StandardError
58
+ ''
59
+ end
60
+ first.start_with?('#!') ? first.sub('#!', '').strip : nil
61
+ end
62
+
63
+ def parse_plugin_metadata(file)
64
+ meta = { 'input' => nil, 'output' => nil, 'name' => nil }
65
+ lines = File.readlines(file, chomp: true)
66
+ return meta if lines.empty?
67
+
68
+ # skip shebang
69
+ i = 0
70
+ i += 1 if lines[0].to_s.start_with?('#!')
71
+ # skip leading blanks
72
+ i += 1 while i < lines.length && lines[i].strip.empty?
73
+ while i < lines.length
74
+ line = lines[i]
75
+ break if line.strip.empty?
76
+
77
+ # strip common comment leaders
78
+ stripped = line.sub(%r{^\s*(#|//)}, '').strip
79
+ if (m = stripped.match(/^([A-Za-z]+)\s*:\s*(.+)$/))
80
+ key = m[1].downcase
81
+ val = m[2].strip
82
+ case key
83
+ when 'input', 'output'
84
+ meta[key] = val.downcase
85
+ when 'name', 'title'
86
+ meta['name'] = val
87
+ end
88
+ end
89
+ break if meta.values_at('input', 'output', 'name').compact.size == 3
90
+
91
+ i += 1
92
+ end
93
+ meta
94
+ end
95
+
96
+ def run_plugin(file, stdin_str)
97
+ interp = shebang_for(file)
98
+ cmd = interp ? %(#{interp} #{Shellwords.escape(file)}) : %(sh #{Shellwords.escape(file)})
99
+ IO.popen(cmd, 'r+', err: %i[child out]) do |io|
100
+ io.write(stdin_str.to_s)
101
+ io.close_write
102
+ io.read
103
+ end
104
+ end
105
+
106
+ def serialize_actions(actions, format: 'json', divider: '||')
107
+ case format.to_s.downcase
108
+ when 'json'
109
+ JSON.pretty_generate(actions)
110
+ when 'yaml', 'yml'
111
+ YAML.dump(actions)
112
+ when 'csv'
113
+ CSV.generate(force_quotes: true) do |csv|
114
+ csv << %w[action arguments file_path line parents text note tags]
115
+ actions.each do |a|
116
+ csv << [
117
+ (a['action'] && a['action']['action']) || 'UPDATE',
118
+ Array(a['action'] && a['action']['arguments']).join(','),
119
+ a['file_path'],
120
+ a['line'],
121
+ Array(a['parents']).join('>'),
122
+ a['text'] || '',
123
+ a['note'] || '',
124
+ serialize_tags(a['tags'])
125
+ ]
126
+ end
127
+ end
128
+ when 'text', 'txt'
129
+ actions.map { |a| serialize_text(a, divider: divider) }.join("\n")
130
+ else
131
+ JSON.generate(actions)
132
+ end
133
+ end
134
+
135
+ def parse_actions(str, format: 'json', divider: '||')
136
+ case format.to_s.downcase
137
+ when 'json'
138
+ JSON.parse(str)
139
+ when 'yaml', 'yml'
140
+ YAML.safe_load(str, permitted_classes: [Time], aliases: true)
141
+ when 'csv'
142
+ rows = CSV.parse(str.to_s, headers: true)
143
+ rows = CSV.parse(str.to_s) if rows.nil? || rows.empty?
144
+ rows.map do |row|
145
+ r = if row.is_a?(CSV::Row)
146
+ row.to_h
147
+ else
148
+ {
149
+ 'action' => row[0], 'arguments' => row[1], 'file_path' => row[2], 'line' => row[3],
150
+ 'parents' => row[4], 'text' => row[5], 'note' => row[6], 'tags' => row[7]
151
+ }
152
+ end
153
+ {
154
+ 'file_path' => r['file_path'].to_s,
155
+ 'line' => r['line'].to_i,
156
+ 'parents' => (r['parents'].to_s.empty? ? [] : r['parents'].split('>').map(&:strip)),
157
+ 'text' => r['text'].to_s,
158
+ 'note' => r['note'].to_s,
159
+ 'tags' => parse_tags(r['tags']),
160
+ 'action' => normalize_action_block(r['action'], r['arguments'])
161
+ }
162
+ end
163
+ when 'text', 'txt'
164
+ str.to_s.split(/\r?\n/).reject(&:empty?).map { |line| parse_text(line, divider: divider) }
165
+ end
166
+ end
167
+
168
+ def serialize_text(action, divider: '||')
169
+ parts = []
170
+ act = action['action'] && action['action']['action']
171
+ args = Array(action['action'] && action['action']['arguments']).join(',')
172
+ parts << (act || 'UPDATE')
173
+ parts << args
174
+ parts << "#{action['file_path']}:#{action['line']}"
175
+ parts << Array(action['parents']).join('>')
176
+ parts << (action['text'] || '')
177
+ parts << (action['note'] || '').gsub("\n", '\\n')
178
+ parts << serialize_tags(action['tags'])
179
+ parts.join(divider)
180
+ end
181
+
182
+ def parse_text(line, divider: '||')
183
+ tokens = line.split(divider, 7)
184
+ action_token = tokens[0].to_s.strip
185
+ if action_name?(action_token)
186
+ act = action_token
187
+ args = tokens[1]
188
+ fileline = tokens[2]
189
+ parents = tokens[3]
190
+ text = tokens[4]
191
+ note = tokens[5]
192
+ tags = tokens[6]
193
+ else
194
+ act = 'UPDATE'
195
+ args = ''
196
+ fileline = tokens[0]
197
+ parents = tokens[1]
198
+ text = tokens[2]
199
+ note = tokens[3]
200
+ tags = tokens[4]
201
+ end
202
+ fp, ln = (fileline || '').split(':', 2)
203
+ {
204
+ 'file_path' => fp.to_s,
205
+ 'line' => ln.to_i,
206
+ 'parents' => (parents.to_s.empty? ? [] : parents.split('>').map(&:strip)),
207
+ 'text' => text.to_s,
208
+ 'note' => note.to_s.gsub('\\n', "\n"),
209
+ 'tags' => parse_tags(tags),
210
+ 'action' => normalize_action_block(act, args)
211
+ }
212
+ end
213
+
214
+ def serialize_tags(tags)
215
+ Array(tags).map { |t| t['value'].to_s.empty? ? t['name'].to_s : %(#{t['name']}(#{t['value']})) }.join(';')
216
+ end
217
+
218
+ def parse_tags(str)
219
+ return [] if str.to_s.strip.empty?
220
+
221
+ str.split(';').map do |part|
222
+ if (m = part.match(/^([^()]+)\((.*)\)$/))
223
+ { 'name' => m[1].strip, 'value' => m[2].to_s }
224
+ else
225
+ { 'name' => part.strip, 'value' => '' }
226
+ end
227
+ end
228
+ end
229
+
230
+ def shebang?(file)
231
+ first = begin
232
+ File.open(file, 'r', &:readline)
233
+ rescue StandardError
234
+ ''
235
+ end
236
+ first.start_with?('#!')
237
+ end
238
+
239
+ def action_name?(name)
240
+ return false if name.to_s.strip.empty?
241
+
242
+ %w[update delete complete finish restore unfinish archive add_tag delete_tag remove_tag move].include?(name.to_s.downcase)
243
+ end
244
+
245
+ def normalize_action_block(action_name, args)
246
+ name = (action_name || 'UPDATE').to_s.upcase
247
+ name = 'DELETE_TAG' if name == 'REMOVE_TAG'
248
+ name = 'COMPLETE' if name == 'FINISH'
249
+ name = 'RESTORE' if name == 'UNFINISH'
250
+ {
251
+ 'action' => name,
252
+ 'arguments' => args.is_a?(Array) ? args : args.to_s.split(/[,;]/).map(&:strip).reject(&:empty?)
253
+ }
254
+ end
255
+
256
+ def default_readme_contents
257
+ <<~MD
258
+ # NA Plugins
259
+
260
+ Put your scripts in this folder. Each plugin must start with a shebang (#!) so NA knows how to execute it.
261
+
262
+ - Plugins receive input on STDIN and must write output to STDOUT
263
+ - Do not modify the original files; NA applies changes based on your output
264
+ - Do not change `file_path` or `line` in your output
265
+ - You may change `parents` (to move), `text`, `note`, and `tags`
266
+
267
+ ## Metadata (optional)
268
+ Add a comment block (after the shebang) with key: value pairs to declare defaults. Keys are case-insensitive.
269
+
270
+ ```
271
+ # input: json
272
+ # output: json
273
+ # name: My Fancy Plugin
274
+ ```
275
+
276
+ CLI flags `--input/--output/--divider` override metadata when provided.
277
+
278
+ ## Formats
279
+ Valid input/output formats: `json`, `yaml`, `csv`, `text`.
280
+
281
+ Text format line:
282
+ ```
283
+ ACTION||ARGS||file_path:line||parents||text||note||tags
284
+ ```
285
+ - If the first token isn’t a known ACTION, it’s treated as `file_path:line` and ACTION defaults to `UPDATE`.
286
+ - `parents`: `Parent>Child>Leaf`
287
+ - `tags`: `name(value);name;other(value)`
288
+
289
+ JSON/YAML object schema per action:
290
+ ```json
291
+ {
292
+ "action": { "action": "UPDATE", "arguments": ["arg1"] },
293
+ "file_path": "/path/to/todo.taskpaper",
294
+ "line": 15,
295
+ "parents": ["Project", "Subproject"],
296
+ "text": "- Do something @tag(value)",
297
+ "note": "Notes can\nspan lines",
298
+ "tags": [ { "name": "tag", "value": "value" } ]
299
+ }
300
+ ```
301
+
302
+ ACTION values (case-insensitive): `UPDATE` (default), `DELETE`, `COMPLETE`/`FINISH`, `RESTORE`/`UNFINISH`, `ARCHIVE`, `ADD_TAG`, `DELETE_TAG`/`REMOVE_TAG`, `MOVE`.
303
+ - For `ADD_TAG`, `DELETE_TAG`/`REMOVE_TAG`, and `MOVE`, provide arguments (e.g., tags or target project).
304
+
305
+ ## Examples
306
+
307
+ JSON input example (2 actions):
308
+ ```json
309
+ [
310
+ {
311
+ "file_path": "/projects/todo.taskpaper",
312
+ "line": 21,
313
+ "parents": ["Inbox"],
314
+ "text": "- Example action",
315
+ "note": "",
316
+ "tags": []
317
+ },
318
+ {
319
+ "file_path": "/projects/todo.taskpaper",
320
+ "line": 42,
321
+ "parents": ["Work", "Feature"],
322
+ "text": "- Add feature @na",
323
+ "note": "Spec TKT-123",
324
+ "tags": [{"name":"na","value":""}]
325
+ }
326
+ ]
327
+ ```
328
+
329
+ Text input example (2 actions):
330
+ ```
331
+ UPDATE||||/projects/todo.taskpaper:21||Inbox||- Example action||||
332
+ MOVE||Work:NewFeature||/projects/todo.taskpaper:42||Work>Feature||- Add feature @na||Spec TKT-123||na
333
+ ```
334
+
335
+ A plugin would read from STDIN, transform, and write the same shape to STDOUT. For example, a shell plugin that adds `@bar`:
336
+ ```bash
337
+ #!/usr/bin/env bash
338
+ # input: text
339
+ # output: text
340
+ while IFS= read -r line; do
341
+ [[ -z "$line" ]] && continue
342
+ IFS='||' read -r a1 a2 a3 a4 a5 a6 a7 <<<"$line"
343
+ # If first token is not an action, treat it as file:line
344
+ case "${a1^^}" in
345
+ UPDATE|DELETE|COMPLETE|FINISH|RESTORE|UNFINISH|ARCHIVE|ADD_TAG|DELETE_TAG|REMOVE_TAG|MOVE) : ;;
346
+ *) a7="$a6"; a6="$a5"; a5="$a4"; a4="$a3"; a3="$a2"; a2=""; a1="UPDATE";;
347
+ esac
348
+ tags="$a7"; tags=${tags:+"$tags;bar"}; tags=${tags:-bar}
349
+ echo "$a1||$a2||$a3||$a4||$a5||$a6||$tags"
350
+ done
351
+ ```
352
+
353
+ Python example (JSON):
354
+ ```python
355
+ #!/usr/bin/env python3
356
+ # input: json
357
+ # output: json
358
+ import sys, json, time
359
+ data = json.load(sys.stdin)
360
+ for a in data:
361
+ act = a.get('action') or {'action':'UPDATE','arguments':[]}
362
+ a['action'] = act
363
+ tags = a.get('tags', [])
364
+ tags.append({'name':'foo','value':time.strftime('%Y-%m-%d %H:%M:%S')})
365
+ a['tags'] = tags
366
+ json.dump(data, sys.stdout)
367
+ ```
368
+
369
+ Tips:
370
+ - Always preserve `file_path` and `line`
371
+ - Return only actions you want changed; others can be omitted
372
+ - For text IO, the field divider defaults to `||` and can be overridden with `--divider`
373
+ MD
374
+ end
375
+
376
+ def create_sample_plugins(dir)
377
+ py = File.join(dir, 'Add Foo.py')
378
+ sh = File.join(dir, 'Add Bar.sh')
379
+ unless File.exist?(py)
380
+ File.write(py, <<~PY)
381
+ #!/usr/bin/env python3
382
+ # name: Add Foo
383
+ # input: json
384
+ # output: json
385
+ import sys, json, time
386
+ data = json.load(sys.stdin)
387
+ now = time.strftime('%Y-%m-%d %H:%M:%S')
388
+ for a in data:
389
+ tags = a.get('tags', [])
390
+ tags.append({'name':'foo','value':now})
391
+ a['tags'] = tags
392
+ json.dump(data, sys.stdout)
393
+ PY
394
+ end
395
+ return if File.exist?(sh)
396
+
397
+ File.write(sh, <<~SH)
398
+ #!/usr/bin/env bash
399
+ # name: Add Bar
400
+ # input: text
401
+ # output: text
402
+ while IFS= read -r line; do
403
+ if [[ -z "$line" ]]; then continue; fi
404
+ if [[ "$line" == *"||"* ]]; then
405
+ fileline=${line%%||*}
406
+ rest=${line#*||}
407
+ parents=${rest%%||*}; rest=${rest#*||}
408
+ text=${rest%%||*}; rest=${rest#*||}
409
+ note=${rest%%||*}; tags=${rest#*||}
410
+ if [[ -z "$tags" ]]; then tags="bar"; else tags="$tags;bar"; fi
411
+ echo "$fileline||$parents||$text||$note||$tags"
412
+ else
413
+ echo "$line"
414
+ fi
415
+ done
416
+ SH
417
+ end
418
+ end
419
+ end
data/lib/na/string.rb CHANGED
@@ -193,18 +193,20 @@ class ::String
193
193
 
194
194
  output = []
195
195
  line = []
196
- length = 0
196
+ # Track visible length of current line (exclude the separating space before first word)
197
+ length = -1
197
198
  text = gsub(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
198
199
 
199
200
  text.split.each do |word|
200
201
  uncolored = NA::Color.uncolor(word)
201
- if (length + uncolored.length + 1) <= width
202
+ candidate = length + 1 + uncolored.length
203
+ if candidate <= width
202
204
  line << word
203
- length += uncolored.length + 1
205
+ length = candidate
204
206
  else
205
207
  output << line.join(' ')
206
208
  line = [word]
207
- length = uncolored.length + 1
209
+ length = uncolored.length
208
210
  end
209
211
  end
210
212
  output << line.join(' ')
data/lib/na/version.rb CHANGED
@@ -5,5 +5,5 @@
5
5
  module Na
6
6
  ##
7
7
  # Current version of the na gem.
8
- VERSION = '1.2.87'
8
+ VERSION = '1.2.88'
9
9
  end
data/lib/na.rb CHANGED
@@ -35,3 +35,4 @@ require 'na/types'
35
35
  require 'na/editor'
36
36
  require 'na/next_action'
37
37
  require 'na/prompt'
38
+ require 'na/plugins'
@@ -0,0 +1,32 @@
1
+ ---
2
+ comment: 2023-09-03
3
+ keywords:
4
+ ---
5
+
6
+ Project3:
7
+ Project0:
8
+ - This is another task @na
9
+ - How about this one? @na
10
+ Subproject:
11
+ - Bollocks @na
12
+ Subsub:
13
+ - Hey, I think it's all working @na
14
+ - Is this at the end? @na
15
+ - This better work @na
16
+ 2023-09-08:
17
+ Project2:
18
+ - new_task @na
19
+ - new_task @na
20
+ - test task @na
21
+ Project0:
22
+ - other task @na
23
+ - other task @na
24
+ - There, that's better @na
25
+ Subproject:
26
+ - new_task 2 @na
27
+ - new_task @na
28
+ - new_task 2 @na
29
+ Project1:
30
+ - Test4
31
+ - Test5
32
+ - Test6
data/na/test.md ADDED
@@ -0,0 +1,21 @@
1
+ ---
2
+ comment: 2023-09-03
3
+ keywords:
4
+ ---
5
+ Other New Project:
6
+ - testing @na @butter
7
+ Brand New Project:
8
+ - testing @na
9
+ A multi line (multiline) note
10
+ with a line break
11
+ - testing @na
12
+ Project0:
13
+
14
+ - Test1
15
+
16
+ - Test2
17
+
18
+ Project1:
19
+ - Test4
20
+ - Test5
21
+ - Test6
data/na.gemspec CHANGED
@@ -24,6 +24,7 @@ spec = Gem::Specification.new do |s|
24
24
  s.add_development_dependency('minitest', '~> 5.14')
25
25
  s.add_development_dependency('rdoc', '~> 4.3')
26
26
  s.add_runtime_dependency('chronic', '~> 0.10', '>= 0.10.2')
27
+ s.add_runtime_dependency('csv', '>= 3.2')
27
28
  s.add_runtime_dependency('git', '~> 3.0.0')
28
29
  s.add_runtime_dependency('gli','~> 2.21.0')
29
30
  s.add_runtime_dependency('mdless', '~> 1.0', '>= 1.0.32')
data/plugins.md ADDED
@@ -0,0 +1,38 @@
1
+ I would like to add a plugin architecture to na_gem. It should allow the user to add plugins to ~/.local/share/na/plugins/. These plugins can be any shell script (with a shebang). They can be run with `na plugin NAME`, which accepts the plugin filename with or without an extension, and with or without spaces (so that `plugin AddFoo` will run `Add Foo.sh` if found, but the user can also use `plugin "Add Foo"`).
2
+
3
+ A plugin will be a shell script that takes input on STDIN. The input should be an action as a JSON object, with the file path, line number, action text, note, and array of tags/values (`tags: [{ name: "done", value: "2025-10-29 03:00"}, { name: "na", value: ""}]`). That should be the default.
4
+
5
+ The `plugin` command should accept a `--input TYPE` flag that accepts `json`, `yaml` or `text`. The YAML should be the same as the JSON (but as YAML), and the text should just be the file_path:line_number, action text, and note, split with "||" (newlines in the note replaced with \n, and filename and line number are combined with : not the divider), with no colorization. One action per line. The "||" in `--input text` should also be a flag `--divider "STRING"` that defaults to "||", but allows the user to specify a different string to split the parts on.
6
+
7
+ The plugin will need to return output (on STDOUT) in the same format as the input (yaml, json, or text with specified divider), unless `--output FORMAT` is specified with a different type. The `plugin` command will execute the script for every command passed to it, and update the actions based on the returned output.
8
+
9
+ The `plugin` command should accept all the same filter flags as `finish` or other actions that update commands.
10
+
11
+ For the `update` command, it should accept a `--plugin NAME` flag, and if it's using interactive menus, a list of plugin names (basename minus extension) should be added to the list of available operations.
12
+
13
+ Also add a `--plugin NAME`, `--input TYPE`, and `--output TYPE` flag to all search and display commands (next, grep, tagged, etc.). That way the user can filter output with any command and run the result through the plugin.
14
+
15
+ In lieu of the `--input` and `--output` commands, the plugin itself can have a comment block after the shebang with `key: value` pairs. When reading a plugin, check for a comment block with `input: JSON` `output: YAML` (case insensitive). The user can also define a `name` or `title` (interchangeable) in this block, which will be used instead of the base name if provided. We need to ignore leading characters when scanning for this comment block (e.g. # or //). The block can have blank lines before it. The only keys we read are input, output, and name/title. Parsing stops at the first blank line or after all three keys are populated. Other keys might exist, like `author` or `description`, which should be ignored.
16
+
17
+ The plugins shouldn't need to be executable, the hashbang should be read and used to execute the script.
18
+
19
+ When `na` runs, it should check for the existence of the `plugins` directory, creating it if it's missing, and adding a `~/.local/share/na/plugsin/README.md` file with plugin instructions if one doesn't exist. Any `.md` or `.bak` file in the plugins directory should be ignored. In fact, let's have a helper validate the files in the directory by checking for a shebang and ignoring if none exists, and also ignoring any '.bak' or '.md' files.
20
+
21
+ Have NA also create 2 sample plugins in the `~/.local/share/na/plugins` folder when creating it (do not create plugins if the folder already exists). Have the sample plugins be a Python script and a Bash script. The sample plugins should just do something benign like add a tag with a dynamic value to the passed actions. In the README.md note that the user can delete the sample plugins. Give the sample plugins names "Add Foo.py" and "Add Bar.sh" and have them add @foo and @bar, respectively.
22
+
23
+ ### Summary ###
24
+
25
+ - plugins are script files in ~/.local/share/na/plugins
26
+ - plugins require a shebang, which is used to execute them
27
+ - plugin base names (without extension) becomes the command name (spaces are handled)
28
+ - Ignore
29
+ - `plugin` subcommand
30
+ - accepts plugin name as argument
31
+ - has a `--input TYPE` flag that determines the input type (yaml, json, or text)
32
+ - has a `--output TYPE` (yaml, json, or text)
33
+ - has a `--divider` flag that determines the divider when `--input text` is used
34
+ - `update` subcommand
35
+ - accepts a `--plugin NAME` flag
36
+ - Adds plugin names to interactive menu when no action is specified
37
+ - main script parses the output of the plugin, stripping whitespace and reading it as YAML, JSON, or text split on the divider (based on `--output` and defaulting to the value of `--input`), then updates each action in the result. Line numbers should be passed on both input and output and used to update the specific actions.
38
+ - Generate README and scripts
data/src/_README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  _If you're one of the rare people like me who find this useful, feel free to
10
10
  [buy me some coffee][donate]._
11
11
 
12
- The current version of `na` is <!--VER-->1.2.86<!--END VER-->.
12
+ The current version of `na` is <!--VER-->1.2.87<!--END VER-->.
13
13
 
14
14
  `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
15
15
 
@@ -375,6 +375,115 @@ If you're using a single global file, you'll need `--cwd_as` to be `tag` or `pro
375
375
 
376
376
  After installing a hook, you'll need to close your terminal and start a new session to initialize the new commands.
377
377
 
378
+ ### Plugins
379
+
380
+ NA supports a plugin system that allows you to run external scripts to transform or process actions. Plugins are stored in `~/.local/share/na/plugins` and can be written in any language with a shebang.
381
+
382
+ #### Getting Started
383
+
384
+ The first time NA runs, it will create the plugins directory with a README and two sample plugins:
385
+ - `Add Foo.py` - Adds a `@foo` tag with a timestamp
386
+ - `Add Bar.sh` - Adds a `@bar` tag
387
+
388
+ You can delete or modify these sample plugins as needed.
389
+
390
+ #### Running Plugins
391
+
392
+ Run a plugin with:
393
+ ```bash
394
+ na plugin PLUGIN_NAME
395
+ ```
396
+
397
+ Or use plugins through the `update` command's interactive menu, or pipe actions through plugins on display commands:
398
+
399
+ ```bash
400
+ na update --plugin PLUGIN_NAME # Run plugin on selected actions
401
+ na next --plugin PLUGIN_NAME # Transform output only (no file writes)
402
+ na tagged bug --plugin PLUGIN_NAME # Filter and transform
403
+ na find "search term" --plugin PLUGIN_NAME
404
+ ```
405
+
406
+ #### Plugin Metadata
407
+
408
+ Plugins can specify their behavior in a metadata block after the shebang:
409
+
410
+ ```bash
411
+ #!/usr/bin/env python3
412
+ # name: My Plugin
413
+ # input: json
414
+ # output: json
415
+ ```
416
+
417
+ Available metadata keys (case-insensitive):
418
+ - `input`: Input format (`json`, `yaml`, `csv`, `text`)
419
+ - `output`: Output format
420
+ - `name` or `title`: Display name (defaults to filename)
421
+
422
+ #### Input/Output Formats
423
+
424
+ Plugins accept and return action data. Use `--input` and `--output` flags to override metadata:
425
+
426
+ ```bash
427
+ na plugin MY_PLUGIN --input text --output json --divider "||"
428
+ ```
429
+
430
+ **JSON/YAML Schema:**
431
+ ```json
432
+ [
433
+ {
434
+ "file_path": "todo.taskpaper",
435
+ "line": 15,
436
+ "parents": ["Project", "Subproject"],
437
+ "text": "- Action text @tag(value)",
438
+ "note": "Note content",
439
+ "tags": [
440
+ { "name": "tag", "value": "value" }
441
+ ]
442
+ }
443
+ ]
444
+ ```
445
+
446
+ **Text Format:**
447
+ ```
448
+ ACTION||ARGS||file_path:line||parents||text||note||tags
449
+ ```
450
+
451
+ Default divider is `||` (configurable with `--divider`).
452
+ - `parents`: `Parent>Child>Leaf`
453
+ - `tags`: `name(value);name;other(value)`
454
+
455
+ If the first token isn’t a known action, it’s treated as `file_path:line` and the action defaults to UPDATE.
456
+
457
+ #### Actions
458
+
459
+ Plugins may return an optional ACTION with arguments. Supported (case-insensitive):
460
+ - UPDATE (default; replace text/note/tags/parents)
461
+ - DELETE
462
+ - COMPLETE/FINISH
463
+ - RESTORE/UNFINISH
464
+ - ARCHIVE
465
+ - ADD_TAG (args: one or more tags)
466
+ - DELETE_TAG/REMOVE_TAG (args: one or more tags)
467
+ - MOVE (args: target project path)
468
+
469
+ #### Plugin Behavior
470
+
471
+ **On `update` or `plugin` command:**
472
+ - Plugins can modify text, notes, tags, and parents
473
+ - Changing `parents` will move the action to the new project location
474
+ - `file_path` and `line` cannot be changed
475
+
476
+ **On display commands (`next`, `tagged`, `find`):**
477
+ - Plugins only transform STDOUT (no file writes)
478
+ - Use returned text/note/tags/parents for rendering
479
+ - Parent changes affect display but not file structure
480
+
481
+ #### Override Formats
482
+
483
+ You can override plugin defaults with flags on any command that supports `--plugin`:
484
+ ```bash
485
+ na next --plugin FOO --input csv --output text
486
+ ```
378
487
 
379
488
  [fzf]: https://github.com/junegunn/fzf
380
489
  [gum]: https://github.com/charmbracelet/gum