about summary refs log tree commit homepage
path: root/bin
diff options
context:
space:
mode:
Diffstat (limited to 'bin')
-rwxr-xr-xbin/dtas-console160
-rwxr-xr-xbin/dtas-ctl10
-rwxr-xr-xbin/dtas-cueedit78
-rwxr-xr-xbin/dtas-enq13
-rwxr-xr-xbin/dtas-graph129
-rwxr-xr-xbin/dtas-msinkctl51
-rwxr-xr-xbin/dtas-play-xover-delay85
-rwxr-xr-xbin/dtas-player34
-rwxr-xr-xbin/dtas-sinkedit46
-rwxr-xr-xbin/dtas-sourceedit39
10 files changed, 645 insertions, 0 deletions
diff --git a/bin/dtas-console b/bin/dtas-console
new file mode 100755
index 0000000..3693c99
--- /dev/null
+++ b/bin/dtas-console
@@ -0,0 +1,160 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+# Note: no idea what I'm doing, especially w.r.t. curses
+require 'dtas/unix_client'
+require 'curses'
+require 'yaml'
+
+w = DTAS::UNIXClient.new
+w.req_ok('watch')
+c = DTAS::UNIXClient.new
+cur = YAML.load(c.req('current'))
+readable = [ w, $stdin ]
+
+def update_tfmt(prec)
+  prec == 0 ? '%H:%M:%S' : "%H:%M:%S.%#{prec}N"
+end
+trap(:INT) { exit(0) }
+trap(:TERM) { exit(0) }
+
+# time precision
+prec_nr = 1
+prec_step = (0..9).to_a
+prec_max = prec_step.size - 1
+tfmt = update_tfmt(prec_step[prec_nr])
+events = []
+interval = 1.0 / 10 ** prec_nr
+
+def show_events(lineno, screen, events)
+  Curses.setpos(lineno += 1, 0)
+  Curses.clrtoeol
+  Curses.addstr('Events:')
+  maxy = screen.maxy - 1
+  maxx = screen.maxx
+  events.reverse_each do |e|
+    Curses.setpos(lineno += 1, 0)
+    Curses.clrtoeol
+    extra = e.size/maxx
+    break if (lineno + extra) >= maxy
+
+    # deal with long lines
+    if extra
+      rewind = lineno
+      extra.times do
+        Curses.setpos(lineno += 1, 0)
+        Curses.clrtoeol
+      end
+      Curses.setpos(rewind, 0)
+      Curses.addstr(e)
+      Curses.setpos(lineno, 0)
+    else
+      Curses.addstr(e)
+    end
+  end
+
+  # discard events we can't show
+  nr_events = events.size
+  if nr_events > maxy
+    events = events[(nr_events - maxy)..-1]
+    until lineno >= screen.maxy
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+    end
+  else
+    Curses.setpos(maxy + 1, 0)
+    Curses.clrtoeol
+  end
+end
+
+begin
+  Curses.init_screen
+  Curses.nonl
+  Curses.cbreak
+  Curses.noecho
+  screen = Curses.stdscr
+  screen.scrollok(true)
+  screen.keypad(true)
+  loop do
+    lineno = -1
+    if current = cur['current']
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+      Curses.addstr(current['infile'])
+
+      elapsed = Time.now.to_f - current['spawn_at']
+      if (nr = cur['current_initial']) && (current_format = current['format'])
+        rate = current_format['rate'].to_f
+        elapsed += nr / rate
+        total = " [#{Time.at(current['samples'] / rate).strftime(tfmt)}]"
+      else
+        total = ""
+      end
+
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+      Curses.addstr("#{Time.at(elapsed).strftime(tfmt)}#{total}")
+    else
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+      Curses.addstr(cur['paused'] ? 'paused' : 'idle')
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+    end
+
+    show_events(lineno, screen, events)
+
+    Curses.refresh # draw and wait
+    r = IO.select(readable, nil, nil, current ? interval : nil) or next
+    r[0].each do |io|
+      case io
+      when w
+        event = w.res_wait
+        events << "#{Time.now.strftime(tfmt)} #{event}"
+        # something happened, refresh current
+        # we could be more intelligent here, maybe, but too much work.
+        cur = YAML.load(c.req('current'))
+      when $stdin
+        # keybindings taken from mplayer / vi
+        case key = Curses.getch
+        when "j" then c.req_ok("seek +5")
+        when "k" then c.req_ok("seek -5")
+        when Curses::KEY_DOWN then c.req_ok("seek -60")
+        when Curses::KEY_UP then c.req_ok("seek +60")
+        when Curses::KEY_LEFT then c.req_ok("seek -10")
+        when Curses::KEY_RIGHT then c.req_ok("seek +10")
+        when Curses::KEY_BACKSPACE then c.req_ok("seek 0")
+        # yes, some of us have long audio files
+        when Curses::KEY_PPAGE then c.req_ok("seek +600")
+        when Curses::KEY_NPAGE then c.req_ok("seek -600")
+        when " "
+          c.req("play_pause")
+        when "p" # lower precision of time display
+          if prec_nr >= 1
+            prec_nr -= 1
+            tfmt = update_tfmt(prec_step[prec_nr])
+            interval = 1.0 / 10 ** prec_nr
+          end
+        when "P" # increase precision of time display
+          if prec_nr < prec_max
+            prec_nr += 1
+            tfmt = update_tfmt(prec_step[prec_nr])
+            interval = 1.0 / 10 ** prec_nr
+          end
+        when 27 # TODO readline/edit mode?
+        else
+          Curses.setpos(screen.maxy - 1, 0)
+          Curses.clrtoeol
+          Curses.addstr("unknown key=#{key.inspect}")
+        end
+      end
+    end
+  end
+rescue EOFError
+  Curses.close_screen
+  abort "dtas-player exited"
+ensure
+  Curses.close_screen
+end
diff --git a/bin/dtas-ctl b/bin/dtas-ctl
new file mode 100755
index 0000000..c05dc28
--- /dev/null
+++ b/bin/dtas-ctl
@@ -0,0 +1,10 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'dtas/unix_client'
+
+# Unix paths are encoding agnostic
+ARGV.map! { |arg| arg.b }
+c = DTAS::UNIXClient.new
+puts c.req(ARGV)
diff --git a/bin/dtas-cueedit b/bin/dtas-cueedit
new file mode 100755
index 0000000..2a205ac
--- /dev/null
+++ b/bin/dtas-cueedit
@@ -0,0 +1,78 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'tempfile'
+require 'shellwords'
+usage = "Usage: #$0 FILENAME"
+editor = ENV["VISUAL"] || ENV["EDITOR"]
+ARGV.size > 0 or abort usage
+
+def err_msg(cmd, status)
+  err_cmd = cmd.map { |f| Shellwords.escape(f) }.join(' ')
+  "E: #{err_cmd} failed: #{status.inspect}"
+end
+
+def x!(*cmd)
+  system(*cmd) or abort err_msg(cmd, $?)
+end
+
+def tmpfile(file, suffix)
+  tmp = Tempfile.new([File.basename(file), suffix])
+  tmp.sync = true
+  tmp.binmode
+  tmp
+end
+
+ARGV.each do |file|
+  # Unix paths are encoding agnostic
+  file = file.b
+  file =~ /\.flac\z/i or warn "Unsupported suffix, assuming FLAC"
+  tmp = tmpfile(file, '.cue')
+  begin
+    # export the temporary file for the user to edit
+    if system(*%W(metaflac --export-cuesheet-to=#{tmp.path} #{file}))
+      remove_existing = true
+      backup = tmpfile(file, '.backup.cue')
+    else
+      remove_existing = false
+      backup = nil
+      tmp.puts 'FILE "dtas-cueedit.tmp.flac" FLAC'
+      tmp.puts '  TRACK 01 AUDIO'
+      tmp.puts '    INDEX 01 00:00:00'
+    end
+
+    # keep a backup, in case the user screws up the edit
+    original = File.binread(tmp.path)
+    backup.write(original) if backup
+
+    # user edits the file
+    x!("#{editor} #{tmp.path}")
+
+    # avoid an expensive update if the user didn't change anything
+    current = File.binread(tmp.path)
+    if current == original
+      $stderr.puts "tags for #{Shellwords.escape(file)} unchanged" if $DEBUG
+      next
+    end
+
+    # we must remove existing tags before importing again
+    if remove_existing
+      x!(*%W(metaflac --remove --block-type=CUESHEET #{file}))
+    end
+
+    # try to import the new file but restore from the original backup if the
+    # user wrote an improperly formatted cue sheet
+    cmd = %W(metaflac --import-cuesheet-from=#{tmp.path} #{file})
+    if ! system(*cmd) && backup
+      warn err_msg(cmd, $?)
+      warn "E: restoring original from backup"
+      x!(*%W(metaflac --import-cuesheet-from=#{backup.path} #{file}))
+      warn "E: backup cuesheet restored, #{Shellwords.escape(file)} unchanged"
+      exit(false)
+    end
+  ensure
+    tmp.close!
+    backup.close! if backup
+  end
+end
diff --git a/bin/dtas-enq b/bin/dtas-enq
new file mode 100755
index 0000000..e8ebd66
--- /dev/null
+++ b/bin/dtas-enq
@@ -0,0 +1,13 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'dtas/unix_client'
+c = DTAS::UNIXClient.new
+
+ARGV.each do |path|
+  # Unix paths are encoding agnostic
+  path = File.expand_path(path.b)
+  res = c.req_ok(%W(enq #{path}))
+  puts "#{path} #{res}"
+end
diff --git a/bin/dtas-graph b/bin/dtas-graph
new file mode 100755
index 0000000..a668d47
--- /dev/null
+++ b/bin/dtas-graph
@@ -0,0 +1,129 @@
+#!/usr/bin/perl -w
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+use strict;
+use Graph::Easy; # for ASCII-art graphs
+$^O =~ /linux/ or print STDERR "$0 probably only works on Linux...\n";
+scalar @ARGV or die "Usage: $0 PID [PID ...]";
+our $procfs = $ENV{PROCFS} || "/proc";
+my $cull_self_pipe = 1;
+
+# returns a list of PIDs which are children of the given PID
+sub children_of {
+        my ($ppid) = @_;
+        my %rv = map {
+                s/\A\s*//g;
+                s/\s*\z//g;
+                my ($pid, $cmd) = split(/\s+/, $_, 2);
+                $pid => $cmd;
+        } `ps h -o pid,cmd --ppid=$ppid`;
+        \%rv;
+}
+
+# pid => [ child pids ]
+my %pids;
+
+# pipe_ino => { r => [ [pid, fd], [pid, fd] ], w => [ [pid, fd], ... ] }
+my %pipes;
+
+# pid => argv
+my %cmds;
+
+my $pipe_nr = 0;
+# pipe_id -> pipe_ino (we use short pipe IDs to save space on small terms)
+my %graphed;
+
+my @to_scan = (@ARGV);
+
+sub cmd_of {
+        my ($pid) = @_;
+        my $cmd = `ps h -o cmd $pid`;
+        chomp $cmd;
+        $cmd;
+}
+
+while (my $pid = shift @to_scan) {
+        my $children = children_of($pid);
+        my @child_pids = keys %$children;
+        push @to_scan, @child_pids;
+        $pids{$pid} = \@child_pids;
+        foreach my $child (keys @child_pids) {
+                $cmds{$child} = $children->{$child};
+        }
+}
+
+# build up a hash of pipes and their connectivity to processes:
+#
+foreach my $pid (keys %pids) {
+        my @out = `lsof -p $pid`;
+        # output is like this:
+        # play    12739   ew    0r  FIFO    0,7      0t0 36924019 pipe
+        foreach my $l (@out) {
+                my @l = split(/\s+/, $l);
+                $l[4] eq "FIFO" or next;
+
+                my $fd = $l[3];
+                my $pipe_ino = $l[7];
+                my $info = $pipes{$pipe_ino} ||= { r => [], w => [] };
+                if ($fd =~ s/r\z//) {
+                        push @{$info->{r}}, [ $pid, $fd ];
+                } elsif ($fd =~ s/w\z//) {
+                        push @{$info->{w}}, [ $pid, $fd ];
+                }
+
+        }
+}
+
+my $graph = Graph::Easy->new();
+foreach my $pid (keys %pids) {
+        $graph->add_node($pid);
+}
+
+foreach my $pipe_ino (keys %pipes) {
+        my $info = $pipes{$pipe_ino};
+        my %pairs;
+        my $pipe_node;
+
+        foreach my $rw (qw(r w)) {
+                foreach my $pidfd (@{$info->{$rw}}) {
+                        my ($pid, $fd) = @$pidfd;
+                        my $pair = $pairs{$pid} ||= {};
+                        my $fds = $pair->{$rw} ||= [];
+                        push @$fds, $fd;
+                }
+        }
+        # use Data::Dumper;
+        # print Dumper(\%pairs);
+        my $nr_pids = scalar keys %pairs;
+
+        foreach my $pid (keys %pairs) {
+                my $pair = $pairs{$pid};
+                my $r = $pair->{r} || [];
+                my $w = $pair->{w} || [];
+                next if $cull_self_pipe && $nr_pids == 1 && @$r && @$w;
+
+                unless ($pipe_node) {
+                        my $pipe_id = $pipe_nr++;
+                        $graphed{$pipe_id} = $pipe_ino;
+                        $pipe_node = "|$pipe_id";
+                        $graph->add_node($pipe_node);
+                }
+
+                $graph->add_edge($pipe_node, $pid, join(',', @$r)) if @$r;
+                $graph->add_edge($pid, $pipe_node, join(',', @$w)) if @$w;
+        }
+}
+
+print "   PID COMMAND\n";
+foreach my $pid (sort { $a <=> $b } keys %pids) {
+        printf "% 6d", $pid;
+        print " ", $cmds{$pid} || cmd_of($pid), "\n";
+}
+
+print "\nPIPEID PIPE_INO\n";
+foreach my $pipe_id (sort { $a <=> $b } keys %graphed) {
+        printf "% 6s", "|$pipe_id";
+        print " ", $graphed{$pipe_id}, "\n";
+}
+
+print $graph->as_ascii;
diff --git a/bin/dtas-msinkctl b/bin/dtas-msinkctl
new file mode 100755
index 0000000..a3cb8ce
--- /dev/null
+++ b/bin/dtas-msinkctl
@@ -0,0 +1,51 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'yaml'
+require 'dtas/unix_client'
+usage = "#$0 <active-set|active-add|active-sub|nonblock|active> SINK"
+c = DTAS::UNIXClient.new
+action = ARGV.shift
+sink_args = ARGV
+
+buf = c.req("sink ls")
+abort(buf) if buf =~ /\AERR/
+player_sinks = buf.split(/ /)
+
+non_existent = sink_args - player_sinks
+non_existent[0] and
+  abort "non-existent sink(s): #{non_existent.join(' ')}"
+
+def activate_sinks(c, sink_names)
+  sink_names.each { |name| c.req_ok("sink ed #{name} active=true") }
+end
+
+def deactivate_sinks(c, sink_names)
+  sink_names.each { |name| c.req_ok("sink ed #{name} active=false") }
+end
+
+def filter(c, player_sinks, key)
+  rv = []
+  player_sinks.each do |name|
+    buf = c.req("sink cat #{name}")
+    sink = YAML.load(buf)
+    rv << sink["name"] if sink[key]
+  end
+  rv
+end
+
+case action
+when "active-set"
+  activate_sinks(c, sink_args)
+  deactivate_sinks(c, player_sinks - sink_args)
+when "active-add" # idempotent
+  activate_sinks(c, sink_args)
+when "active-sub"
+  deactivate_sinks(c, sink_args)
+when "active", "nonblock"
+  abort "`#$0 #{action}' takes no arguments" if sink_args[0]
+  puts filter(c, player_sinks, action).join(' ')
+else
+  abort usage
+end
diff --git a/bin/dtas-play-xover-delay b/bin/dtas-play-xover-delay
new file mode 100755
index 0000000..d5b6533
--- /dev/null
+++ b/bin/dtas-play-xover-delay
@@ -0,0 +1,85 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+USAGE = "Usage: #$0 [-x FREQ] [-l] /dev/fd/LO /dev/fd/HI DELAY [DELAY ...]"
+require 'optparse'
+dryrun = false
+xover = '80'
+delay_lo = []
+delay_hi = []
+adj_delay = delay_hi
+out_channels = out_rate = out_type = nil
+
+lowpass = 'lowpass %s lowpass %s'
+highpass = 'highpass %s highpass %s'
+
+op = OptionParser.new('', 24, '  ') do |opts|
+  opts.banner = USAGE
+  opts.on('-x', '--crossover-frequency FREQ') do |freq|
+    xover = freq
+  end
+  opts.on('-l', '--lowpass-delay') { adj_delay = delay_lo }
+  opts.on('-c', '--channels INTEGER') { |val| out_channels = val }
+  opts.on('-r', '--rate RATE') { |val| out_rate = val }
+  opts.on('-t', '--type FILE-TYPE') { |val| out_type = val }
+  opts.on('-n', '--dry-run') { dryrun = true }
+  opts.on('--lowpass FORMAT_STRING') { |s| lowpass = s }
+  opts.on('--highpass FORMAT_STRING') { |s| highpass = s }
+  opts.parse!(ARGV)
+end
+
+dev_fd_lo = ARGV.shift
+dev_fd_hi = ARGV.shift
+if ARGV.delete('-')
+  # we re-add the '-' below
+  out_channels && out_rate && out_type or
+    abort "-c, -r, and -t must all be specified for standard output"
+  cmd = "sox"
+elsif out_channels || out_rate || out_type
+  abort "standard output (`-') must be specified with -c, -r, or -t"
+else
+  cmd = "play"
+end
+soxfmt = ENV["SOXFMT"] or abort "#$0 SOXFMT undefined"
+
+# configure the sox "delay" effect
+delay = ARGV.dup
+delay[0] or abort USAGE
+channels = ENV['CHANNELS'] or abort "#$0 CHANNELS env must be set"
+channels = channels.to_i
+adj_delay.replace(delay.dup)
+until adj_delay.size == channels
+  adj_delay << delay.last
+end
+adj_delay.unshift("delay")
+
+# prepare two inputs:
+delay_lo = delay_lo.join(' ')
+delay_hi = delay_hi.join(' ')
+
+lowpass_args = []
+lowpass.gsub('%s') { |s| lowpass_args << xover; s }
+highpass_args = []
+highpass.gsub('%s') { |s| highpass_args << xover; s }
+
+lo = "|exec sox #{soxfmt} #{dev_fd_lo} -p " \
+     "#{sprintf(lowpass, *lowpass_args)} #{delay_lo}".strip
+hi = "|exec sox #{soxfmt} #{dev_fd_hi} -p " \
+     "#{sprintf(highpass, *highpass_args)} #{delay_hi}".strip
+
+args = [ "-m", "-v1", lo, "-v1", hi ]
+case cmd
+when "sox"
+  args.unshift "sox"
+  args.concat(%W(-t#{out_type} -c#{out_channels} -r#{out_rate} -))
+when "play"
+  args.unshift "play"
+else
+  abort "BUG: bad cmd=#{cmd.inspect}"
+end
+if dryrun
+  p args
+else
+  exec *args, close_others: false
+end
diff --git a/bin/dtas-player b/bin/dtas-player
new file mode 100755
index 0000000..8b78771
--- /dev/null
+++ b/bin/dtas-player
@@ -0,0 +1,34 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+Thread.abort_on_exception = $stderr.sync = $stdout.sync = true
+require 'yaml'
+require 'dtas/player'
+sock = (ENV["DTAS_PLAYER_SOCK"] ||
+        File.expand_path("~/.dtas/player.sock")).b
+state = (ENV["DTAS_PLAYER_STATE"] ||
+         File.expand_path("~/.dtas/player_state.yml")).b
+[ sock, state ].each do |file|
+  dir = File.dirname(file)
+  next if File.directory?(dir)
+  require 'fileutils'
+  FileUtils.mkpath(dir)
+end
+
+state = DTAS::StateFile.new(state)
+if tmp = state.tryload
+  tmp["socket"] ||= sock
+  player = DTAS::Player.load(tmp)
+  player.state_file ||= state
+else
+  player = DTAS::Player.new
+  player.state_file = state
+  player.socket = sock
+end
+
+at_exit { player.close }
+player.bind
+trap(:INT) { exit }
+trap(:TERM) { exit }
+player.run
diff --git a/bin/dtas-sinkedit b/bin/dtas-sinkedit
new file mode 100755
index 0000000..d1e8fb4
--- /dev/null
+++ b/bin/dtas-sinkedit
@@ -0,0 +1,46 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'dtas/unix_client'
+require 'dtas/disclaimer'
+require 'tempfile'
+require 'yaml'
+editor = ENV["VISUAL"] || ENV["EDITOR"]
+c = DTAS::UNIXClient.new
+usage = "#$0 SINKNAME"
+ARGV.size == 1 or abort usage
+name = ARGV[0]
+
+tmp = Tempfile.new(%w(dtas-sinkedit .yml))
+tmp.sync = true
+tmp.binmode
+
+buf = c.req(%W(sink cat #{name}))
+abort(buf) if buf =~ /\AERR/
+buf << DTAS_DISCLAIMER
+
+tmp.write(buf)
+cmd = "#{editor} #{tmp.path}"
+system(cmd) or abort "#{cmd} failed: #$?"
+tmp.rewind
+sink = YAML.load(tmp.read)
+
+cmd = %W(sink ed #{name})
+if env = sink["env"]
+  env.each do |k,v|
+    cmd << (v.nil? ? "env##{k}" : "env.#{k}=#{v}")
+  end
+end
+
+%w(nonblock active).each do |field|
+  if sink.key?(field)
+    cmd << "#{field}=#{sink[field] ? 'true' : 'false'}"
+  end
+end
+
+%w(prio pipe_size command).each do |field|
+  value = sink[field] and cmd << "#{field}=#{value}"
+end
+
+c.req_ok(cmd)
diff --git a/bin/dtas-sourceedit b/bin/dtas-sourceedit
new file mode 100755
index 0000000..9d329d7
--- /dev/null
+++ b/bin/dtas-sourceedit
@@ -0,0 +1,39 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'tempfile'
+require 'yaml'
+require 'dtas/unix_client'
+require 'dtas/disclaimer'
+editor = ENV["VISUAL"] || ENV["EDITOR"]
+c = DTAS::UNIXClient.new
+usage = $0
+ARGV.size == 0 or abort usage
+name = ARGV[0]
+
+tmp = Tempfile.new(%w(dtas-sourceedit .yml))
+tmp.sync = true
+tmp.binmode
+
+buf = c.req(%W(source cat))
+abort(buf) if buf =~ /\AERR/
+
+tmp.write(buf << DTAS_DISCLAIMER)
+cmd = "#{editor} #{tmp.path}"
+system(cmd) or abort "#{cmd} failed: #$?"
+tmp.rewind
+source = YAML.load(tmp.read)
+
+cmd = %W(source ed)
+if env = source["env"]
+  env.each do |k,v|
+    cmd << (v.nil? ? "env##{k}" : "env.#{k}=#{v}")
+  end
+end
+
+%w(command).each do |field|
+  value = source[field] and cmd << "#{field}=#{value}"
+end
+
+c.req_ok(cmd)