composer remove --dev $(composer show -s | grep -E "^[a-z]+/[0-9a-z_-]+" | awk '{print $1}' | xargs)
is not the same as:
composer remove --dev "projectx/package-nice projecty/package-good"
The $(cmd) part, because it is not quoted and in list context (in arguments to a simple command here) is subject to split+glob.
Unless you've modified it, $IFS (which is used for the split part) happens to contain the space character, so if cmd outputs projectx/package-nice projecty/package-good\n, that will be split into projectx/package-nice and projecty/package-good and passed as separate arguments to composer.
By the way, newline is also in the default value of $IFS, so your xargs (which I suppose is there to convert the newlines to spaces¹) is rather pointless.
Rather than using split+glob, if using the bash shell, using readarray to read the lines of some file into individual elements of an array would make more sense:
readarray -t packages < <(
composer show -s | grep -Po '^\p{Ll}+/[\p{Ll}\d_-]\H*'
)
(( ${#packages[@]} == 0 )) ||
composer remove --dev "${packages[@]}"
Using split+glob is also an option, but as always, when using it it's best to tune it for your exact need:
IFS=$'\n' # split on newline only
set -o noglob # disable the glob part which we don't want
packages=( $(cmd...) ) # split+glob, result assigned to an array
In your case, the output of cmd should not contain space nor tab characters, the other two characters that are in the default value of $IFS in bash, so you could leave $IFS as is.
But it might contain globs. For instance, if composer show -s output etc/p* blah blah for instance, your pipeline would output etc/p*, and if run from within /, without the set -o noglob, that etc/p* would be expanded to etc/pam.conf, etc/passwd, etc/profile...
To prevent split+glob, for the output of cmd (minus the trailing newlines that are stripped by command-substitution) to be passed as one and only one argument to the command, you use double quote:
composer remove --dev "$(cmd)"
(which only makes sense if cmd outputs only one package).
On Linux, you can see what arguments are being passed to a command using
strace -s999999 -qqfe execve the-command and its args
(or run that strace command on your shell to trace all the execve() system calls that it or any of the processes it spawns make)
For instance:
split+glob with the default value of IFS:
bash-5.0$ strace -s999999 -qqfe execve true $(echo foo; echo foo bar)
execve("/usr/bin/true", ["true", "foo", "foo", "bar"], 0x7ffe1374cc50 /* 66 vars */) = 0
Splitting on newline only:
bash-5.0$ (IFS=$'\n'; strace -s999999 -qqfe execve true $(echo foo; echo; echo foo bar))
execve("/usr/bin/true", ["true", "foo", "foo bar"], 0x7ffd16c547f8 /* 66 vars */) = 0
(note that the empty line is removed).
Effect of the glob part:
bash-5.0$ (strace -s999999 -qqfe execve true $(echo 'etc/p*'))
execve("/usr/bin/true", ["true", "etc/pam.conf", "etc/pam.d", "etc/papersize", "etc/parallel", "etc/passwd", "etc/passwd-", "etc/pcmcia", "etc/perl", "etc/php", "etc/pki", "etc/pm", "etc/pnm2ppa.conf", "etc/polkit-1", "etc/popularity-contest.conf", "etc/ppp", "etc/printcap", "etc/profile", "etc/profile.d", "etc/protocols", "etc/pulse", "etc/python2.7", "etc/python3", "etc/python3.8"], 0x7ffdc911f8a0 /* 66 vars */) = 0
Fixed with set -o noglob:
bash-5.0$ (set -o noglob; strace -s999999 -qqfe execve true $(echo 'etc/p*'))
execve("/usr/bin/true", ["true", "etc/p*"], 0x7ffe9c278a50 /* 66 vars */) = 0
Split+glob disabled by quoting:
bash-5.0$ (IFS=$'\n'; strace -s999999 -qqfe execve true "$(echo foo; echo; echo foo bar; echo 'etc/p*')")
execve("/usr/bin/true", ["true", "foo\n\nfoo bar\netc/p*"], 0x7ffcf0e70d20 /* 66 vars */) = 0
In zsh, only the IFS-splitting part is done upon unquoted command substitution, not the glob part (ksh also does brace expansion in addition to split+glob). In zsh, you can also apply an explicit splitting on top of command substitution using the s, f, 0 parameter expansion flags. For instance ${(f)"$(cmd)"} splits the output of cmd on newlines, so here, you'd do:
packages=( ${(f)"$(composer show -s | grep -Po '^\p{Ll}+/[\p{Ll}\d_-]\H*')"} )
(( $#packages == 0 )) || composer remove --dev $packages
Without having to modify $IFS nor disable globbing globally.
¹ the wrong way to do it as there are many ways that could fail for arbitrary input and is quite inefficient.
jqand extract the package names into an array.