Среда командной строки

Как мы уже говорили на предыдущей лекции, большинство командных оболочек (shell) – это не просто пусковая площадка для других программ: на практике они предоставляют целый язык программирования со своими устоявшимися паттернами и абстракциями. Однако, в отличие от большинства языков программирования, в shell-скриптах всё построено вокруг запуска программ и того, чтобы они просто и эффективно общались друг с другом.

В частности, программирование на shell жёстко связано соглашениями. Чтобы программа с интерфейсом командной строки (command line interface, CLI) хорошо уживалась с остальным окружением оболочки, она должна следовать ряду общепринятых паттернов. Сейчас мы разберём многие концепции, необходимые для понимания того, как устроены программы командной строки, а также повсеместные соглашения о том, как их использовать и настраивать.

Интерфейс командной строки

В большинстве языков программирования функция выглядит примерно так:

def add(x: int, y: int) -> int:
    return x + y

Здесь мы явно видим входные и выходные данные программы. Shell-скрипты же на первый взгляд могут выглядеть совсем иначе.

#!/usr/bin/env bash

if [[ -f $1 ]]; then
    echo "Target file already exists"
    exit 1
else
    if $DEBUG; then
        grep 'error' - | tee $1
    else
        grep 'error' - > $1
    fi
    exit 0
fi

Чтобы как следует понять, что происходит в подобных скриптах, нам сначала нужно ввести несколько понятий, которые часто встречаются, когда программы в оболочке общаются друг с другом или с окружением оболочки:

Аргументы

Программы в командной оболочке при запуске получают список аргументов. В shell аргументы – это просто строки, и как их интерпретировать, решает сама программа. Например, когда мы выполняем ls -l folder/, мы запускаем программу /bin/ls с аргументами ['-l', 'folder/'].

Внутри shell-скрипта к ним обращаются через специальный синтаксис оболочки. Первый аргумент доступен через переменную $1, второй – через $2 и так далее вплоть до $9. Чтобы получить все аргументы в виде списка, используем $@, а чтобы узнать их количество – $#. Кроме того, через $0 можно получить имя самой программы.

У большинства программ аргументы представляют собой смесь флагов и обычных строк. Флаг легко опознать: перед ним стоит дефис (-) или двойной дефис (--). Флаги обычно необязательны, и их задача – изменять поведение программы. Например, ls -l меняет то, как ls форматирует свой вывод.

Вам будут встречаться флаги с двойным дефисом и длинными именами вроде --all, а также флаги с одинарным дефисом вроде -a, за которым чаще всего следует одна буква. Одна и та же опция может быть задана в обоих форматах: ls -a и ls --all эквивалентны. Флаги с одинарным дефисом часто группируют, так что ls -l -a и ls -la тоже эквивалентны. Порядок флагов обычно тоже не важен: ls -la и ls -al дают одинаковый результат. Некоторые флаги встречаются очень часто, и по мере знакомства со средой командной оболочки вы начнёте интуитивно тянуться к ним (например, --help, --verbose, --version).

Флаги – первый хороший пример соглашений (conventions), принятых в shell. Сам язык оболочки не требует, чтобы наша программа использовала - или -- именно таким образом. Ничто не мешает нам написать программу с синтаксисом myprogram +myoption myfile, но это приведёт к путанице, ведь все ожидают, что мы используем дефисы. На практике большинство языков программирования предоставляют библиотеки для разбора флагов CLI (например, argparse в Python для разбора аргументов с дефисным синтаксисом).

Ещё одно распространённое соглашение для CLI-программ – принимать переменное число аргументов одного типа. Получив аргументы в таком виде, команда выполняет одну и ту же операцию над каждым из них.

mkdir src
mkdir docs
# is equivalent to
mkdir src docs

Поначалу этот синтаксический сахар может показаться лишним, но в сочетании с подстановкой (globbing) он становится по-настоящему мощным. Подстановка, или глобы (globs), – это специальные шаблоны, которые оболочка раскрывает до вызова программы.

Допустим, мы хотим удалить все файлы .py в текущей папке, не заходя в подкаталоги. Пользуясь тем, что мы узнали на предыдущей лекции, мы могли бы добиться этого, запустив

for file in $(ls | grep -P '\.py$'); do
    rm "$file"
done

Но всё это можно заменить простым rm *.py!

Когда мы набираем rm *.py в терминале, оболочка не станет вызывать программу /bin/rm с аргументами ['*.py']. Вместо этого она поищет в текущей папке файлы, подходящие под шаблон *.py, где * соответствует любой строке из нуля или более символов любого типа. Так что если в нашей папке лежат main.py и utils.py, то программа rm получит аргументы ['main.py', 'utils.py'].

Самые распространённые глобы, которые вам встретятся, – это wildcard-символы * (ноль или более чего угодно), ? (ровно один любой символ) и фигурные скобки. Фигурные скобки {} раскрывают разделённый запятыми список шаблонов в несколько аргументов.

На практике глобы лучше всего понимать на наглядных примерах.

touch folder/{a,b,c}.py
# Will expand to
touch folder/a.py folder/b.py folder/c.py

convert image.{png,jpg}
# Will expand to
convert image.png image.jpg

cp /path/to/project/{setup,build,deploy}.sh /newpath
# Will expand to
cp /path/to/project/setup.sh /path/to/project/build.sh /path/to/project/deploy.sh /newpath

# Globbing techniques can also be combined
mv *{.py,.sh} folder
# Will move all *.py and *.sh files

Некоторые оболочки (например, zsh) поддерживают ещё более продвинутые формы подстановки, такие как **, которая раскрывается с учётом рекурсивных путей. Так что rm **/*.py удалит все файлы .py рекурсивно.

Потоки

Всякий раз, когда мы запускаем конвейер программ вроде

cat myfile | grep -P '\d+' | uniq -c

мы видим, что программа grep общается и с программой cat, и с программой uniq.

Важное наблюдение: все три программы выполняются одновременно. То есть оболочка не вызывает сначала cat, потом grep, а потом uniq. Вместо этого все три программы запускаются сразу, а оболочка соединяет вывод cat со входом grep и вывод grep со входом uniq. Используя оператор конвейера |, оболочка работает с потоками данных, которые текут от одной программы к следующей в цепочке.

Мы можем продемонстрировать эту параллельность – все команды в конвейере стартуют немедленно:

$ (sleep 15 && cat numbers.txt) | grep -P '^\d$' | sort | uniq  &
[1] 12345
$ ps | grep -P '(sleep|cat|grep|sort|uniq)'
  32930 pts/1    00:00:00 sleep
  32931 pts/1    00:00:00 grep
  32932 pts/1    00:00:00 sort
  32933 pts/1    00:00:00 uniq
  32948 pts/1    00:00:00 grep

Видно, что все процессы, кроме cat, работают сразу же. Оболочка порождает все процессы и соединяет их потоки до того, как хоть один из них завершится. cat запустится только после того, как отработает sleep, а вывод cat будет отправлен в grep, и так далее.

У каждой программы есть входной поток, называемый stdin (от standard input – стандартный ввод). При работе через конвейер stdin подключается автоматически. В скриптах многие программы принимают - в качестве имени файла, что означает «читать из stdin»:

# These are equivalent when data comes from a pipe
echo "hello" | grep "hello"
echo "hello" | grep "hello" -

Аналогично у каждой программы есть два выходных потока: stdout и stderr. Стандартный вывод – тот, который встречается чаще всего, и именно он используется, чтобы передать вывод программы по конвейеру следующей команде. Стандартный поток ошибок – альтернативный поток, предназначенный для предупреждений и прочих сообщений о проблемах: этот вывод не попадает на разбор следующей команде в цепочке.

$ ls /nonexistent
ls: cannot access '/nonexistent': No such file or directory
$ ls /nonexistent | grep "pattern"
ls: cannot access '/nonexistent': No such file or directory
# The error message still appears because stderr is not piped
$ ls /nonexistent 2>/dev/null
# No output - stderr was redirected to /dev/null

Оболочка предоставляет синтаксис для перенаправления этих потоков. Вот несколько наглядных примеров.

# Redirect stdout to a file (overwrite)
echo "hello" > output.txt

# Redirect stdout to a file (append)
echo "world" >> output.txt

# Redirect stderr to a file
ls foobar 2> errors.txt

# Redirect both stdout and stderr to the same file
ls foobar &> all_output.txt

# Redirect stdin from a file
grep "pattern" < input.txt

# Discard output by redirecting to /dev/null
cmd > /dev/null 2>&1

Ещё одна мощная программа, воплощающая философию Unix, – fzf, инструмент нечёткого поиска (fuzzy finder). Он читает строки из stdin и предоставляет интерактивный интерфейс для фильтрации и выбора:

$ ls | fzf
$ cat ~/.bash_history | fzf

fzf можно встроить во множество операций оболочки. Мы увидим и другие способы его применения, когда будем обсуждать настройку оболочки.

Переменные окружения

Чтобы присвоить значение переменной в bash, мы используем синтаксис foo=bar, а затем обращаемся к значению переменной через $foo. Обратите внимание, что foo = bar – некорректный синтаксис: оболочка разберёт его как вызов программы foo с аргументами ['=', 'bar']. В shell-скриптах роль пробела – разделять аргументы. Такое поведение может сбивать с толку, и к нему непросто привыкнуть, так что имейте это в виду.

У переменных оболочки нет типов – все они строки. Заметьте, что при записи строковых выражений в оболочке одинарные и двойные кавычки не взаимозаменяемы. Строки в кавычках ' – это буквальные строки: в них не подставляются переменные, не выполняется подстановка команд и не обрабатываются escape-последовательности, тогда как в строках в кавычках " всё это происходит.

foo=bar
echo "$foo"
# prints bar
echo '$foo'
# prints $foo

Чтобы записать вывод команды в переменную, используется подстановка команд (command substitution). Когда мы выполняем

files=$(ls)
echo "$files" | grep README
echo "$files" | grep ".py"

вывод (а точнее, stdout) команды ls помещается в переменную $files, к которой мы можем обратиться позже. Содержимое переменной $files включает и переводы строк из вывода ls – именно поэтому программы вроде grep понимают, что каждый элемент нужно обрабатывать по отдельности.

Менее известная похожая возможность – подстановка процессов (process substitution): <( CMD ) выполнит CMD, поместит вывод во временный файл и подставит вместо <() имя этого файла. Это удобно, когда команды ожидают, что значения будут переданы через файл, а не через STDIN. Например, diff <(ls src) <(ls docs) покажет различия между файлами в каталогах src и docs.

Всякий раз, когда одна программа в оболочке вызывает другую, она передаёт ей набор переменных, которые обычно называют переменными окружения. Находясь в оболочке, мы можем посмотреть текущие переменные окружения, выполнив printenv. Чтобы явно передать переменную окружения, можно поставить присваивание переменной перед командой

Переменные окружения по соглашению пишутся ЗАГЛАВНЫМИ_БУКВАМИ (например, HOME, PATH, DEBUG). Это соглашение, а не техническое требование, но его соблюдение помогает отличать переменные окружения от локальных переменных оболочки, которые обычно пишутся строчными.

TZ=Asia/Tokyo date  # prints the current time in Tokyo
echo $TZ  # this will be empty, since TZ was only set for the child command

Кроме того, можно воспользоваться встроенной функцией export, которая изменит наше текущее окружение, и тогда все дочерние процессы унаследуют эту переменную:

export DEBUG=1
# All programs from this point onwards will have DEBUG=1 in their environment
bash -c 'echo $DEBUG'
# prints 1

Чтобы удалить переменную, используйте встроенную команду unset, например unset DEBUG.

Переменные окружения – ещё одно соглашение оболочки. С их помощью можно менять поведение многих программ неявно, а не явно. Например, оболочка записывает в переменную окружения $HOME путь к домашнему каталогу текущего пользователя. Программы могут прочитать эту переменную и получить нужную информацию, вместо того чтобы требовать явного --home /home/alice. Другой распространённый пример – $TZ: многие программы используют её, чтобы форматировать даты и время в соответствии с указанным часовым поясом.

Коды возврата

Как мы уже видели, основной результат работы программы в оболочке передаётся через потоки stdout/stderr и через побочные эффекты в файловой системе.

По умолчанию shell-скрипт возвращает код возврата ноль. По соглашению ноль означает, что всё прошло хорошо, а ненулевое значение – что возникли какие-то проблемы. Чтобы вернуть ненулевой код возврата, нужно использовать встроенную команду оболочки exit NUM. Код возврата последней выполненной команды можно получить через специальную переменную $?.

В оболочке есть логические операторы && и ||, выполняющие операции И и ИЛИ соответственно. В отличие от аналогичных операторов в обычных языках программирования, в оболочке они работают с кодами возврата программ. Оба этих оператора – короткозамкнутые (short-circuiting). Это значит, что с их помощью можно запускать команды в зависимости от того, успешно или неуспешно завершились предыдущие, причём успех определяется тем, равен ли код возврата нулю. Несколько примеров:

# echo will only run if grep succeeds (finds a match)
grep -q "pattern" file.txt && echo "Pattern found"

# echo will only run if grep fails (no match)
grep -q "pattern" file.txt || echo "Pattern not found"

# true is a shell program that always succeeds
true && echo "This will always print"

# and false is a shell program that always fails
false || echo "This will always print"

Тот же принцип действует для конструкций if и while – обе они принимают решения на основе кодов возврата:

# if uses the return code of the condition command (0 = true, nonzero = false)
if grep -q "pattern" file.txt; then
    echo "Found"
fi

# while loops continue as long as the command returns 0
while read line; do
    echo "$line"
done < file.txt

Сигналы

Иногда программу нужно прервать прямо во время выполнения – например, если команда выполняется слишком долго. Самый простой способ прервать программу – нажать Ctrl-C, и команда, скорее всего, остановится. Но как это на самом деле работает и почему иногда процесс всё-таки не останавливается?

$ sleep 100
^C
$

Обратите внимание: ^C – так Ctrl-C отображается при вводе в терминале.

Под капотом произошло следующее:

  1. Мы нажали Ctrl-C
  2. Оболочка распознала эту специальную комбинацию символов
  3. Процесс оболочки отправил процессу sleep сигнал SIGINT
  4. Сигнал прервал выполнение процесса sleep

Сигналы – это особый механизм взаимодействия. Получив сигнал, процесс приостанавливает выполнение, обрабатывает сигнал и, возможно, меняет ход своей работы в зависимости от того, какую информацию этот сигнал принёс. Поэтому сигналы называют программными прерываниями (software interrupts).

В нашем случае нажатие Ctrl-C заставляет оболочку доставить процессу сигнал SIGINT. Вот минимальный пример программы на Python, которая перехватывает SIGINT и игнорирует его, из-за чего больше не останавливается. Чтобы убить такую программу, теперь можно воспользоваться сигналом SIGQUIT, нажав Ctrl-\.

#!/usr/bin/env python
import signal, time

def handler(signum, time):
    print("\nI got a SIGINT, but I am not stopping")

signal.signal(signal.SIGINT, handler)
i = 0
while True:
    time.sleep(.1)
    print("\r{}".format(i), end="")
    i += 1

Вот что происходит, если отправить этой программе SIGINT дважды, а затем SIGQUIT. Обратите внимание: ^ – так Ctrl отображается при вводе в терминале.

$ python sigint.py
24^C
I got a SIGINT, but I am not stopping
26^C
I got a SIGINT, but I am not stopping
30^\[1]    39913 quit       python sigint.py

Хотя и SIGINT, и SIGQUIT обычно связаны с запросами со стороны терминала, более универсальный сигнал, которым процесс просят корректно завершиться, – это SIGTERM. Отправить его можно командой kill с синтаксисом kill -TERM <PID>.

Сигналы умеют не только убивать процессы. Например, SIGSTOP приостанавливает процесс. В терминале нажатие Ctrl-Z заставит оболочку отправить сигнал SIGTSTP – сокращение от Terminal Stop (то есть терминальная версия SIGSTOP).

Затем приостановленное задание можно продолжить на переднем плане или в фоне с помощью fg или bg соответственно.

Команда jobs выводит список незавершённых заданий, связанных с текущей сессией терминала. Ссылаться на эти задания можно по их pid (узнать его можно с помощью pgrep). Более наглядный способ – ссылаться на процесс через знак процента и номер задания (его показывает jobs). Чтобы обратиться к последнему отправленному в фон заданию, можно использовать специальный параметр $!.

Ещё стоит знать, что суффикс & у команды запускает её в фоне и сразу возвращает вам приглашение командной строки, хотя команда по-прежнему будет писать в STDOUT оболочки, что может раздражать (в таком случае используйте перенаправления оболочки). Аналогично, чтобы отправить в фон уже запущенную программу, можно нажать Ctrl-Z, а затем выполнить bg.

Обратите внимание, что фоновые процессы всё равно остаются дочерними процессами вашего терминала и умрут, если вы его закроете (при этом отправляется ещё один сигнал – SIGHUP). Чтобы этого не произошло, можно запустить программу через nohup (обёртку, игнорирующую SIGHUP) или воспользоваться disown, если процесс уже запущен. Как вариант, можно использовать мультиплексор терминала – о нём мы поговорим в следующем разделе.

Ниже приведён пример сеанса, демонстрирующий некоторые из этих понятий.

$ sleep 1000
^Z
[1]  + 18653 suspended  sleep 1000

$ nohup sleep 2000 &
[2] 18745
appending output to nohup.out

$ jobs
[1]  + suspended  sleep 1000
[2]  - running    nohup sleep 2000

$ kill -SIGHUP %1
[1]  + 18653 hangup     sleep 1000

$ kill -SIGHUP %2   # nohup protects from SIGHUP

$ jobs
[2]  + running    nohup sleep 2000

$ kill %2
[2]  + 18745 terminated  nohup sleep 2000

Особый сигнал – SIGKILL: процесс не может его перехватить, и этот сигнал всегда немедленно завершает процесс. Однако у него могут быть неприятные побочные эффекты – например, осиротевшие дочерние процессы.

Подробнее об этих и других сигналах можно почитать здесь, а также набрав man signal или kill -l.

В shell-скриптах можно использовать встроенную команду trap, чтобы выполнять команды при получении сигналов. Это удобно для операций очистки:

#!/usr/bin/env bash
cleanup() {
    echo "Cleaning up temporary files..."
    rm -f /tmp/mytemp.*
}
trap cleanup EXIT  # Run cleanup when script exits
trap cleanup SIGINT SIGTERM  # Also on Ctrl-C or kill

Удалённые машины

Программистам всё чаще приходится работать с удалёнными серверами в повседневных задачах. Самый распространённый инструмент для этого – SSH (Secure Shell), который позволяет подключиться к удалённому серверу и получить уже знакомый нам интерфейс командной оболочки. К серверу мы подключаемся командой вроде:

ssh alice@server.mit.edu

Здесь мы пытаемся подключиться по ssh как пользователь alice к серверу server.mit.edu.

Часто упускаемая из виду возможность ssh – запуск команд в неинтерактивном режиме. ssh корректно передаёт stdin и принимает stdout команды, поэтому его можно комбинировать с другими командами

# here ls runs in the remote, and wc runs locally
ssh alice@server ls | wc -l

# here both ls and wc run in the server
ssh alice@server 'ls | wc -l'

Попробуйте установить Mosh – замену SSH, которая умеет переживать обрывы соединения, уход в сон и выход из него, смену сети и справляется с каналами с высокой задержкой.

Чтобы ssh позволил нам выполнять команды на удалённом сервере, нужно доказать, что у нас есть на это право. Сделать это можно с помощью пароля или ssh-ключей. Аутентификация по ключу использует криптографию с открытым ключом, чтобы доказать серверу, что клиент владеет секретным закрытым ключом, не раскрывая сам ключ. Аутентификация по ключу одновременно и удобнее, и безопаснее, так что предпочитайте именно её. Учтите, что закрытый ключ (обычно ~/.ssh/id_rsa, а в последнее время ~/.ssh/id_ed25519) – это фактически ваш пароль, так что относитесь к нему соответственно и никогда никому не показывайте его содержимое.

Чтобы сгенерировать пару ключей, выполните ssh-keygen.

ssh-keygen -a 100 -t ed25519 -f ~/.ssh/id_ed25519

Если вы когда-нибудь настраивали push в GitHub по SSH-ключам, то, скорее всего, уже проделали шаги, описанные здесь, и у вас есть действующая пара ключей. Проверить, есть ли у ключа парольная фраза, и убедиться, что она подходит, можно командой ssh-keygen -y -f /path/to/key.

На стороне сервера ssh заглядывает в .ssh/authorized_keys, чтобы решить, каких клиентов пускать. Скопировать туда открытый ключ можно так:

cat .ssh/id_ed25519.pub | ssh alice@remote 'cat >> ~/.ssh/authorized_keys'

# or more simply (if ssh-copy-id is available)

ssh-copy-id -i .ssh/id_ed25519 alice@remote

Помимо запуска команд, соединение, которое устанавливает ssh, можно использовать для безопасной передачи файлов на сервер и с него. scp – самый традиционный инструмент, его синтаксис: scp path/to/local_file remote_host:path/to/remote_file. rsync идёт дальше scp: он находит одинаковые файлы на локальной и удалённой машине и не копирует их повторно. Кроме того, он даёт более тонкий контроль над символическими ссылками и правами доступа, а также имеет дополнительные возможности вроде флага --partial, который позволяет продолжить прерванное ранее копирование. Синтаксис у rsync похож на scp.

Конфигурация SSH-клиента находится в ~/.ssh/config и позволяет объявлять хосты и задавать для них настройки по умолчанию. Этот файл конфигурации читает не только ssh, но и другие программы: scp, rsync, mosh и т. д.

Host vm
    User alice
    HostName 172.16.174.141
    Port 2222
    IdentityFile ~/.ssh/id_ed25519

# Configs can also take wildcards
Host *.mit.edu
    User alice

Мультиплексоры терминала

При работе в интерфейсе командной строки вам часто будет нужно запускать несколько программ одновременно. Например, вам может понадобиться держать рядом редактор и свою программу. Этого можно добиться, открыв новые окна терминала, но более гибкое решение – воспользоваться мультиплексором терминала.

Мультиплексоры терминала вроде tmux позволяют мультиплексировать окна терминала с помощью панелей и вкладок, чтобы вы могли эффективно работать с несколькими сессиями оболочки. Кроме того, мультиплексоры терминала позволяют отсоединиться от текущей сессии терминала и снова подключиться к ней позже. Благодаря этому мультиплексоры терминала очень удобны при работе с удалёнными машинами: отпадает нужда в nohup и подобных трюках.

Самый популярный мультиплексор терминала на сегодня – tmux. tmux очень гибко настраивается, и с помощью связанных с ним сочетаний клавиш можно создавать множество вкладок и панелей и быстро перемещаться между ними.

tmux ожидает, что вы знаете его сочетания клавиш, и все они имеют вид <C-b> x, что означает: (1) нажать Ctrl+b, (2) отпустить Ctrl+b, а затем (3) нажать x. В tmux есть следующая иерархия объектов:

Чтобы узнать больше о tmux, почитайте этот краткий туториал и это более подробное объяснение.

Когда в вашем арсенале есть tmux и SSH, вам захочется, чтобы на любой машине окружение ощущалось как родное. Здесь-то и вступает в игру настройка оболочки.

Настройка оболочки

Огромное множество программ командной строки настраивается с помощью текстовых файлов, известных как dotfiles (потому что их имена начинаются с ., например ~/.vimrc, и поэтому по умолчанию они скрыты в списке содержимого каталога, который выводит ls).

Dotfiles – ещё одно соглашение оболочки. Точка в начале имени нужна, чтобы «спрятать» их при выводе списка файлов (да, тоже соглашение).

Оболочки – один из примеров программ, которые настраиваются такими файлами. При запуске ваша оболочка читает множество файлов, чтобы загрузить свою конфигурацию. В зависимости от оболочки и от того, запускаете ли вы login- и/или интерактивную сессию, весь этот процесс может быть довольно сложным. Вот отличный материал на эту тему.

Для bash в большинстве систем достаточно отредактировать .bashrc или .bash_profile. Вот ещё несколько примеров инструментов, которые можно настраивать через dotfiles:

Одно из типичных изменений конфигурации – добавление новых мест, где оболочка будет искать программы. Этот приём вы не раз встретите при установке программ:

export PATH="$PATH:path/to/append"

Здесь мы говорим оболочке присвоить переменной $PATH её текущее значение плюс новый путь и передать это новое значение PATH по наследству всем дочерним процессам. Благодаря этому дочерние процессы смогут находить программы, расположенные в path/to/append.

Настройка оболочки часто означает установку новых инструментов командной строки. Менеджеры пакетов сильно упрощают эту задачу. Они берут на себя скачивание, установку и обновление программ. В разных операционных системах разные менеджеры пакетов: macOS использует Homebrew, Ubuntu/Debian – apt, Fedora – dnf, а Arch – pacman. Подробнее о менеджерах пакетов мы поговорим в лекции об упаковке и доставке кода.

Вот как установить два полезных инструмента с помощью Homebrew на macOS:

# ripgrep: a faster grep with better defaults
brew install ripgrep

# fd: a faster, user-friendly find
brew install fd

После их установки можно использовать rg вместо grep и fd вместо find.

Предупреждение насчёт curl | bash: вы часто будете видеть инструкции по установке вроде curl -fsSL https://example.com/install.sh | bash. Этот шаблон скачивает скрипт и тут же его выполняет – удобно, но рискованно: вы запускаете код, который не проверили. Более безопасный подход – сначала скачать, посмотреть, а потом уже выполнять:

curl -fsSL https://example.com/install.sh -o install.sh
less install.sh  # review the script
bash install.sh

Некоторые установщики используют чуть более безопасный вариант: /bin/bash -c "$(curl -fsSL https://url)" – он хотя бы гарантирует, что интерпретировать скрипт будет именно bash, а не ваша текущая оболочка.

Когда вы пытаетесь запустить команду, которая не установлена, оболочка выведет command not found. Сайт command-not-found.com – полезный ресурс, на котором можно найти любую команду и узнать, как установить её в разных менеджерах пакетов и дистрибутивах.

Ещё один полезный инструмент – tldr: он предоставляет упрощённые man-страницы, построенные вокруг примеров. Вместо того чтобы читать длинную документацию, можно быстро посмотреть типичные варианты использования:

$ tldr fd
  An alternative to find.
  Aims to be faster and easier to use than find.

  Recursively find files matching a pattern in the current directory:
      fd "pattern"

  Find files that begin with "foo":
      fd "^foo"

  Find files with a specific extension:
      fd --extension txt

Иногда нужна не целая новая программа, а всего лишь короткое имя для существующей команды с определёнными флагами. Тут на помощь приходят алиасы (псевдонимы).

Мы также можем создавать собственные алиасы команд с помощью встроенной команды оболочки alias. Алиас оболочки – это сокращённая запись другой команды; перед вычислением выражения оболочка автоматически заменит его полной командой. Например, алиас в bash имеет следующую структуру:

alias alias_name="command_to_alias arg1 arg2"

Обратите внимание, что вокруг знака равенства = нет пробелов, потому что alias – это команда оболочки, принимающая один аргумент.

У алиасов много удобных возможностей:

# Make shorthands for common flags
alias ll="ls -lh"

# Save a lot of typing for common commands
alias gs="git status"
alias gc="git commit"

# Save you from mistyping
alias sl=ls

# Overwrite existing commands for better defaults
alias mv="mv -i"           # -i prompts before overwrite
alias mkdir="mkdir -p"     # -p make parent dirs as needed
alias df="df -h"           # -h prints human readable format

# Alias can be composed
alias la="ls -A"
alias lla="la -l"

# To ignore an alias run it prepended with \
\ls
# Or disable an alias altogether with unalias
unalias la

# To get an alias definition just call it with alias
alias ll
# Will print ll='ls -lh'

У алиасов есть ограничения: они не могут принимать аргументы в середине команды. Для более сложного поведения стоит использовать вместо них функции оболочки.

Большинство оболочек поддерживают Ctrl-R для обратного поиска по истории. Нажмите Ctrl-R и начните печатать, чтобы искать среди ранее выполненных команд. Ранее мы уже познакомились с fzf – инструментом нечёткого поиска; если настроить интеграцию fzf с оболочкой, Ctrl-R превращается в интерактивный нечёткий поиск по всей вашей истории – куда более мощный, чем поиск по умолчанию.

Как организовать свои dotfiles? Они должны лежать в отдельной папке, находиться под контролем версий и расставляться по своим местам скриптом через символические ссылки. Это даёт следующие преимущества:

Что стоит положить в свои dotfiles? О настройках инструмента можно узнать из онлайн-документации или man-страниц. Другой отличный способ – поискать в интернете блог-посты о конкретных программах, где авторы рассказывают о своих любимых настройках. Ещё один способ узнать о возможных настройках – заглядывать в чужие dotfiles: на GitHub можно найти массу репозиториев с dotfiles – самый популярный смотрите здесь (впрочем, мы не советуем слепо копировать чужие конфигурации). Вот ещё один хороший ресурс по этой теме.

Dotfiles всех преподавателей курса открыто доступны на GitHub: Anish, Jon, Jose.

Фреймворки и плагины тоже могут улучшить вашу оболочку. Из популярных универсальных фреймворков – prezto или oh-my-zsh; есть и плагины поменьше, сосредоточенные на конкретных возможностях:

Оболочки вроде fish включают многие из этих возможностей по умолчанию.

Чтобы получить эти возможности, не обязательно ставить громоздкий фреймворк вроде oh-my-zsh. Установка отдельных плагинов часто быстрее и даёт больше контроля. Большие фреймворки могут заметно замедлять запуск оболочки, так что подумайте о том, чтобы ставить только то, чем вы действительно пользуетесь.

ИИ в командной оболочке

Способов встроить ИИ-инструменты в командную оболочку много. Вот несколько примеров с разной глубиной интеграции:

Генерация команд: инструменты вроде simonw/llm помогают генерировать команды оболочки из описания на естественном языке:

$ llm cmd "find all python files modified in the last week"
find . -name "*.py" -mtime -7

Интеграция в конвейеры: LLM можно встраивать в конвейеры команд для обработки и преобразования данных. Они особенно полезны, когда нужно извлечь информацию из неоднородных форматов, где регулярные выражения превратились бы в мучение:

$ cat users.txt
Contact: john.doe@example.com
User 'alice_smith' logged in at 3pm
Posted by: @bob_jones on Twitter
Author: Jane Doe (jdoe)
Message from mike_wilson yesterday
Submitted by user: sarah.connor
$ INSTRUCTIONS="Extract just the username from each line, one per line, nothing else"
$ llm "$INSTRUCTIONS" < users.txt
john.doe
alice_smith
bob_jones
jdoe
mike_wilson
sarah.connor

Обратите внимание: мы пишем "$INSTRUCTIONS" (в кавычках), потому что переменная содержит пробелы, а < users.txt перенаправляет содержимое файла в stdin.

ИИ-оболочки: инструменты вроде Claude Code действуют как метаоболочка, которая принимает команды на английском языке и превращает их в операции оболочки, правки файлов и более сложные многошаговые задачи.

Эмуляторы терминала

Помимо настройки оболочки, стоит потратить немного времени на выбор эмулятора терминала и его настройку. Эмулятор терминала – это GUI-программа, предоставляющая текстовый интерфейс, в котором работает ваша оболочка. Эмуляторов терминала существует великое множество.

Поскольку в терминале вы, возможно, проведёте сотни, а то и тысячи часов, изучить его настройки – выгодное вложение времени. Вот некоторые аспекты терминала, которые может иметь смысл настроить под себя:

Упражнения

Аргументы и подстановка (globbing)

  1. Вам могут встречаться команды вида cmd --flag -- --notaflag. -- – это специальный аргумент, который говорит программе прекратить разбор флагов. Всё, что идёт после --, считается позиционным аргументом. Чем это может быть полезно? Попробуйте выполнить touch -- -myfile, а затем удалить этот файл без --.

  2. Прочитайте man ls и составьте команду ls, которая выводит список файлов следующим образом:
    • В список входят все файлы, включая скрытые
    • Размеры выводятся в человекочитаемом формате (например, 454M вместо 454279954)
    • Файлы упорядочены по времени изменения, начиная с самых свежих
    • Вывод раскрашен цветом

    Пример вывода может выглядеть так:

     -rw-r--r--   1 user group 1.1M Jan 14 09:53 baz
     drwxr-xr-x   5 user group  160 Jan 14 09:53 .
     -rw-r--r--   1 user group  514 Jan 14 06:42 bar
     -rw-r--r--   1 user group 106M Jan 13 12:12 foo
     drwx------+ 47 user group 1.5K Jan 12 18:08 ..
    
  3. Подстановка процессов (process substitution) <(command) позволяет использовать вывод команды так, как будто это файл. С помощью diff и подстановки процессов сравните вывод printenv и export. Почему они различаются? (Подсказка: попробуйте diff <(printenv | sort) <(export | sort)).

Переменные окружения

  1. Напишите bash-функции marco и polo, которые делают следующее: каждый раз, когда вы выполняете marco, текущий рабочий каталог должен каким-либо образом сохраняться, а когда вы выполняете polo – в каком бы каталоге вы ни находились, polo должна возвращать вас (cd) в тот каталог, где вы выполнили marco. Для удобства отладки можно записать код в файл marco.sh и (пере)загружать определения в оболочку командой source marco.sh.

Коды возврата

  1. Допустим, у вас есть команда, которая сбоит редко. Чтобы отладить её, нужно перехватить её вывод, но дожидаться неудачного запуска может быть долго. Напишите bash-скрипт, который запускает следующий скрипт до тех пор, пока тот не завершится с ошибкой, сохраняет его потоки стандартного вывода и ошибок в файлы и в конце печатает всё это. Дополнительные баллы, если вы к тому же сообщите, сколько запусков понадобилось, чтобы скрипт упал.

     #!/usr/bin/env bash
    
     n=$(( RANDOM % 100 ))
    
     if [[ $n -eq 42 ]]; then
        echo "Something went wrong"
        >&2 echo "The error was using magic numbers"
        exit 1
     fi
    
     echo "Everything went according to plan"
    

Сигналы и управление заданиями

  1. Запустите в терминале задание sleep 10000, отправьте его в фон с помощью Ctrl-Z и возобновите его выполнение командой bg. Теперь с помощью pgrep найдите его pid, а с помощью pkill завершите его, ни разу не набрав сам pid. (Подсказка: используйте флаги -lf).

  2. Допустим, вы не хотите запускать процесс, пока не завершится другой. Как бы вы это сделали? В этом упражнении ограничивающим процессом всегда будет sleep 60 &. Один из способов добиться этого – использовать команду wait. Попробуйте запустить команду sleep и заставить ls подождать, пока фоновый процесс не закончится.

    Однако эта стратегия не сработает, если запустить её из другой bash-сессии, потому что wait работает только с дочерними процессами. Одна особенность, которую мы не обсуждали в конспекте: код возврата команды kill равен нулю при успехе и отличен от нуля в противном случае. kill -0 не посылает сигнал, но вернёт ненулевой код возврата, если процесс не существует. Напишите bash-функцию pidwait, которая принимает pid и ждёт, пока указанный процесс не завершится. Используйте sleep, чтобы попусту не тратить процессорное время.

Файлы и права доступа

  1. (Продвинутое) Напишите команду или скрипт, чтобы рекурсивно найти в каталоге файл, изменённый последним. А в более общем виде: сможете ли вы вывести все файлы в порядке давности изменения?

Мультиплексоры терминала

  1. Пройдите этот туториал по tmux, а затем научитесь делать базовые настройки, следуя этим шагам.

Алиасы и dotfiles

  1. Создайте алиас dc, который разворачивается в cd, – на случай опечатки.

  2. Выполните history | awk '{$1="";print substr($0,2)}' | sort | uniq -c | sort -n | tail -n 10, чтобы получить топ-10 самых используемых вами команд, и подумайте, не завести ли для них более короткие алиасы. Примечание: это работает в Bash; если вы пользуетесь ZSH, используйте history 1 вместо просто history.

  3. Создайте папку для своих dotfiles и настройте контроль версий.

  4. Добавьте конфигурацию хотя бы для одной программы, например для своей оболочки, с какой-нибудь настройкой (для начала это может быть что-то совсем простое, вроде настройки приглашения командной строки через переменную $PS1).

  5. Придумайте способ быстро (и без ручных действий) устанавливать свои dotfiles на новой машине. Это может быть простой shell-скрипт, вызывающий ln -s для каждого файла, а можно воспользоваться специализированной утилитой.

  6. Проверьте свой скрипт установки на свежей виртуальной машине.

  7. Перенесите все текущие конфигурации своих инструментов в репозиторий с dotfiles.

  8. Опубликуйте свои dotfiles на GitHub.

Удалённые машины (SSH)

Для этих упражнений установите виртуальную машину с Linux (или используйте уже существующую). Если вы не знакомы с виртуальными машинами, загляните в этот туториал по её установке.

  1. Зайдите в ~/.ssh/ и проверьте, есть ли у вас там пара SSH-ключей. Если нет, сгенерируйте их командой ssh-keygen -a 100 -t ed25519. Рекомендуется использовать пароль и ssh-agent, подробнее здесь.

  2. Отредактируйте .ssh/config, добавив в него такую запись:

     Host vm
         User username_goes_here
         HostName ip_goes_here
         IdentityFile ~/.ssh/id_ed25519
         LocalForward 9999 localhost:8888
    
  3. С помощью ssh-copy-id vm скопируйте свой ssh-ключ на сервер.

  4. Запустите веб-сервер в своей VM, выполнив python -m http.server 8888. Откройте веб-сервер VM, перейдя по адресу http://localhost:9999 на своей машине.

  5. Отредактируйте конфигурацию своего SSH-сервера командой sudo vim /etc/ssh/sshd_config и отключите аутентификацию по паролю, изменив значение PasswordAuthentication. Запретите вход под root, изменив значение PermitRootLogin. Перезапустите службу ssh командой sudo service sshd restart. Попробуйте снова зайти по ssh.

  6. (Со звёздочкой) Установите mosh в VM и подключитесь. Затем отключите сетевой адаптер сервера/VM. Сможет ли mosh корректно восстановиться после этого?

  7. (Со звёздочкой) Разберитесь, что делают флаги -N и -f у ssh, и составьте команду для проброса портов в фоновом режиме.


Редактировать страницу.

Лицензия CC BY-NC-SA.