diff --git a/.forgejo/workflows/main.yaml b/.forgejo/workflows/main.yaml
new file mode 100644
index 0000000..bcc505a
--- /dev/null
+++ b/.forgejo/workflows/main.yaml
@@ -0,0 +1,18 @@
+on: [push]
+jobs:
+ job:
+ container:
+ image: ${{vars.DOCKER}}debian:bookworm
+ steps:
+ - name: spcd
+ env:
+ SPCD: ${{vars.SPCD}}
+ SPCD_SSH_HOSTS: ${{vars.SPCD_SSH_HOSTS}}
+ SPCD_SSH_KEY: ${{secrets.SPCD_SSH_KEY}}
+ SPCD_TXT_LOCALE: ${{vars.SPCD_TXT_LOCALE}}
+ run: ${{vars.SPCD}}
+
+ #- run: spcd-check-project
+ - run: spcd-build-project
+ - run: spcd-browse-workspace
+ - run: spcd-synchronize
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4909e08
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__
+/tmp
+/.venv
+/.vscode
+/dist
diff --git a/.shellcheckrc b/.shellcheckrc
new file mode 100644
index 0000000..8e71c00
--- /dev/null
+++ b/.shellcheckrc
@@ -0,0 +1,4 @@
+disable=3043
+enable=all
+external-sources=true
+shell=sh
diff --git a/build.py b/build.py
new file mode 100755
index 0000000..3a0a957
--- /dev/null
+++ b/build.py
@@ -0,0 +1,11 @@
+#! /usr/bin/env python3
+"""Dummy build."""
+
+from pathlib import Path
+
+from rwx.fs import make_directory, write
+
+if __name__ == "__main__":
+ out = Path(__file__).parent / "out" / "web"
+ make_directory(out)
+ write(out / "index.html", "rwx.rwx.work")
diff --git a/license.md b/license.md
new file mode 100644
index 0000000..c6f01c6
--- /dev/null
+++ b/license.md
@@ -0,0 +1,660 @@
+# GNU AFFERO GENERAL PUBLIC LICENSE
+
+Version 3, 19 November 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+
+
+Everyone is permitted to copy and distribute verbatim copies of this
+license document, but changing it is not allowed.
+
+## Preamble
+
+The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains
+free software for all its users.
+
+When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing
+under this license.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+## TERMS AND CONDITIONS
+
+### 0. Definitions.
+
+"This License" refers to version 3 of the GNU Affero General Public
+License.
+
+"Copyright" also means copyright-like laws that apply to other kinds
+of works, such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of
+an exact copy. The resulting work is called a "modified version" of
+the earlier work or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user
+through a computer network, with no transfer of a copy, is not
+conveying.
+
+An interactive user interface displays "Appropriate Legal Notices" to
+the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+### 1. Source Code.
+
+The "source code" for a work means the preferred form of the work for
+making modifications to it. "Object code" means any non-source form of
+a work.
+
+A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users can
+regenerate automatically from other parts of the Corresponding Source.
+
+The Corresponding Source for a work in source code form is that same
+work.
+
+### 2. Basic Permissions.
+
+All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not convey,
+without conditions so long as your license otherwise remains in force.
+You may convey covered works to others for the sole purpose of having
+them make modifications exclusively for you, or provide you with
+facilities for running those works, provided that you comply with the
+terms of this License in conveying all material for which you do not
+control copyright. Those thus making or running the covered works for
+you must do so exclusively on your behalf, under your direction and
+control, on terms that prohibit them from making any copies of your
+copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the
+conditions stated below. Sublicensing is not allowed; section 10 makes
+it unnecessary.
+
+### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such
+circumvention is effected by exercising rights under this License with
+respect to the covered work, and you disclaim any intention to limit
+operation or modification of the work as a means of enforcing, against
+the work's users, your or third parties' legal rights to forbid
+circumvention of technological measures.
+
+### 4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+### 5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these
+conditions:
+
+- a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+- b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under
+ section 7. This requirement modifies the requirement in section 4
+ to "keep intact all notices".
+- c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+- d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+### 6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms of
+sections 4 and 5, provided that you also convey the machine-readable
+Corresponding Source under the terms of this License, in one of these
+ways:
+
+- a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+- b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the Corresponding
+ Source from a network server at no charge.
+- c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+- d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+- e) Convey the object code using peer-to-peer transmission,
+ provided you inform other peers where the object code and
+ Corresponding Source of the work are being offered to the general
+ public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal,
+family, or household purposes, or (2) anything designed or sold for
+incorporation into a dwelling. In determining whether a product is a
+consumer product, doubtful cases shall be resolved in favor of
+coverage. For a particular product received by a particular user,
+"normally used" refers to a typical or common use of that class of
+product, regardless of the status of the particular user or of the way
+in which the particular user actually uses, or expects or is expected
+to use, the product. A product is a consumer product regardless of
+whether the product has substantial commercial, industrial or
+non-consumer uses, unless such uses represent the only significant
+mode of use of the product.
+
+"Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to
+install and execute modified versions of a covered work in that User
+Product from a modified version of its Corresponding Source. The
+information must suffice to ensure that the continued functioning of
+the modified object code is in no case prevented or interfered with
+solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or
+updates for a work that has been modified or installed by the
+recipient, or for the User Product in which it has been modified or
+installed. Access to a network may be denied when the modification
+itself materially and adversely affects the operation of the network
+or violates the rules and protocols for communication across the
+network.
+
+Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+### 7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders
+of that material) supplement the terms of this License with terms:
+
+- a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+- b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+- c) Prohibiting misrepresentation of the origin of that material,
+ or requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+- d) Limiting the use for publicity purposes of names of licensors
+ or authors of the material; or
+- e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+- f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions
+ of it) with contractual assumptions of liability to the recipient,
+ for any liability that these contractual assumptions directly
+ impose on those licensors and authors.
+
+All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions; the
+above requirements apply either way.
+
+### 8. Termination.
+
+You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+However, if you cease all violation of this License, then your license
+from a particular copyright holder is reinstated (a) provisionally,
+unless and until the copyright holder explicitly and finally
+terminates your license, and (b) permanently, if the copyright holder
+fails to notify you of the violation by some reasonable means prior to
+60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+### 9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or run
+a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+### 10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+### 11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims owned
+or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+A patent license is "discriminatory" if it does not include within the
+scope of its coverage, prohibits the exercise of, or is conditioned on
+the non-exercise of one or more of the rights that are specifically
+granted under this License. You may not convey a covered work if you
+are a party to an arrangement with a third party that is in the
+business of distributing software, under which you make payment to the
+third party based on the extent of your activity of conveying the
+work, and under which the third party grants, to any of the parties
+who would receive the covered work from you, a discriminatory patent
+license (a) in connection with copies of the covered work conveyed by
+you (or copies made from those copies), or (b) primarily for and in
+connection with specific products or compilations that contain the
+covered work, unless you entered into that arrangement, or that patent
+license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+### 12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under
+this License and any other pertinent obligations, then as a
+consequence you may not convey it at all. For example, if you agree to
+terms that obligate you to collect a royalty for further conveying
+from those to whom you convey the Program, the only way you could
+satisfy both those terms and this License would be to refrain entirely
+from conveying the Program.
+
+### 13. Remote Network Interaction; Use with the GNU General Public License.
+
+Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your
+version supports such interaction) an opportunity to receive the
+Corresponding Source of your version by providing access to the
+Corresponding Source from a network server at no charge, through some
+standard or customary means of facilitating copying of software. This
+Corresponding Source shall include the Corresponding Source for any
+work covered by version 3 of the GNU General Public License that is
+incorporated pursuant to the following paragraph.
+
+Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+### 14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions
+of the GNU Affero General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever
+published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions
+of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+### 15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
+WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
+PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
+DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
+CORRECTION.
+
+### 16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
+CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
+ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
+NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
+LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
+TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
+PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+### 17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+END OF TERMS AND CONDITIONS
+
+## How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these
+terms.
+
+To do so, attach the following notices to the program. It is safest to
+attach them to the start of each source file to most effectively state
+the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation, either version 3 of the
+ License, or (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper
+mail.
+
+If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for
+the specific requirements.
+
+You should also get your employer (if you work as a programmer) or
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. For more information on this, and how to apply and follow
+the GNU AGPL, see .
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..7a3d9c9
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,32 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+authors = [
+ { name = "Marc Beninca", email = "git@marc.beninca.link" },
+]
+maintainers = [
+ { name = "Marc Beninca", email = "git@marc.beninca.link" },
+]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: GNU Affero General Public License v3",
+ "Operating System :: OS Independent",
+]
+dependencies = []
+description = "Read Write eXecute"
+dynamic = ["version"]
+keywords = []
+license-files = ["license.md"]
+name = "rwx"
+readme = "readme.md"
+requires-python = ">= 3.11"
+
+[project.scripts]
+# command = "package.module:function"
+
+[project.urls]
+
+[tool.hatch.version]
+path = "rwx/__init__.py"
diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..905c975
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,74 @@
+# Read Write eXecute
+
+A tiny framework to read, write & execute things.
+
+---
+
+## Why
+
+---
+
+## How
+
+---
+
+## What
+
+---
+
+## Who
+
+### By
+
+* [Marc Beninca](https://marc.beninca.link)
+
+### For
+
+* myself
+
+---
+
+## Where
+
+### Chat
+
+* [Discord](https://discord.com/channels/983145051985154108/1255894474895134761)
+* [IRC](ircs://irc.libera.chat/##rwx)
+
+### Forge
+
+* [Repository](https://forge.rwx.work/rwx.work/rwx)
+* [RSS](https://forge.rwx.work/rwx.work/rwx.rss)
+* [Workflows](https://forge.rwx.work/rwx.work/rwx/actions)
+
+### Deployment
+
+* [Site](https://rwx.rwx.work)
+
+---
+
+## When
+
+### Task stack
+
+#### Python
+
+* character constants for box drawing
+* common __str__ function
+* parse pyproject.toml to write commands
+* write classes for
+ * steps bars to log
+ * system commands to run
+ * with single call of subprocess.run
+ * or alternate subprocess method?
+
+#### Shell
+
+* git switch signing commits & tags
+* shellcheck & shfmt
+* python tools
+* log
+* hetzner
+* apt
+ * apt-file search | grep
+* ffmpeg
diff --git a/rwx/__init__.py b/rwx/__init__.py
new file mode 100644
index 0000000..3dbfd68
--- /dev/null
+++ b/rwx/__init__.py
@@ -0,0 +1,33 @@
+"""Read Write eXecute."""
+
+__version__ = "0.0.1"
+
+from os import linesep
+
+
+class Object:
+ """Root object."""
+
+ def __repr__(self) -> str:
+ """Return machine-readable state.
+
+ :return: state
+ :rtype: str
+ """
+ name = self.__class__.__name__
+ attributes = [
+ f"{k}={v!r}" for k, v in vars(self).items() if not k.startswith("_")
+ ]
+ arguments = ", ".join(attributes)
+ return f"{name}({arguments})"
+
+ def __str__(self) -> str:
+ """Return human-readable state.
+
+ :return: state
+ :rtype: str
+ """
+ attributes = [
+ f"{k} = {v}" for k, v in vars(self).items() if not k.startswith("_")
+ ]
+ return linesep.join(attributes)
diff --git a/rwx/__main__.py b/rwx/__main__.py
new file mode 100755
index 0000000..e19dea1
--- /dev/null
+++ b/rwx/__main__.py
@@ -0,0 +1,19 @@
+#! /usr/bin/env python3
+
+"""Entry point."""
+
+from pathlib import Path
+
+from rwx import fs
+
+if __name__ == "__main__":
+ file_path: Path = Path(__file__).resolve()
+ root_path: Path = file_path.parent
+ directory_path: Path = root_path / "tmp"
+ file_path = directory_path / "file"
+
+ fs.wipe(directory_path)
+ fs.make_directory(directory_path)
+ fs.write(file_path, "Martine écrit beaucoup.")
+ fs.empty_file(file_path)
+ fs.write(file_path, "Martine écrit moins.")
diff --git a/rwx/arg/__init__.py b/rwx/arg/__init__.py
new file mode 100644
index 0000000..a35dd4f
--- /dev/null
+++ b/rwx/arg/__init__.py
@@ -0,0 +1,13 @@
+"""Handle system arguments."""
+
+import sys
+
+
+def split() -> tuple[str, list[str]]:
+ """Split command & actual arguments.
+
+ :return: both
+ :rtype: tuple[str, list[str]]
+ """
+ command, *arguments = sys.argv
+ return command, arguments
diff --git a/rwx/cmd/__init__.py b/rwx/cmd/__init__.py
new file mode 100644
index 0000000..be9e540
--- /dev/null
+++ b/rwx/cmd/__init__.py
@@ -0,0 +1,22 @@
+"""Handle system commands & packages."""
+
+commands: list[str] = []
+packages: list[str] = []
+
+
+def need(command: str) -> None:
+ """Assert package dependency for a command.
+
+ :param command: name of the requested command
+ :type command: str
+ """
+ package: str | None
+ match command:
+ case "debootstrap":
+ package = "debootstrap"
+ case "mksquashfs" | "unsquashfs":
+ package = "squashfs-tools"
+ case _:
+ package = None
+ if package and package not in packages:
+ packages.append(package)
diff --git a/rwx/cmd/squashfs/__init__.py b/rwx/cmd/squashfs/__init__.py
new file mode 100644
index 0000000..b3ec864
--- /dev/null
+++ b/rwx/cmd/squashfs/__init__.py
@@ -0,0 +1,26 @@
+"""Wrap SquashFS commands."""
+
+from pathlib import Path
+
+from rwx import cmd, ps
+
+cmd.need("mksquashfs")
+
+
+def mksquashfs(input_root: Path, output_file: Path) -> None:
+ """Make a SquashFS bootable image file.
+
+ :param input_root: ?
+ :type input_root: Path
+ :param output_file: ?
+ :type output_file: Path
+ """
+ ps.run(
+ "mksquashfs",
+ str(input_root),
+ str(output_file),
+ "-comp",
+ "zstd",
+ "-Xcompression-level",
+ str(18),
+ )
diff --git a/rwx/deb/__init__.py b/rwx/deb/__init__.py
new file mode 100644
index 0000000..2537321
--- /dev/null
+++ b/rwx/deb/__init__.py
@@ -0,0 +1,31 @@
+"""Wrap Debian commands."""
+
+from pathlib import Path
+
+from rwx import cmd, ps
+
+cmd.need("debootstrap")
+
+BOOTSTRAP_ARCHITECTURE = "amd64"
+BOOTSTRAP_VARIANT = "minbase"
+
+
+def bootstrap(root_path: Path, suite: str, mirror_location: str) -> None:
+ """Boostrap a base operating filesystem.
+
+ :param root_path: target output path
+ :type root_path: Path
+ :param suite: target distribution name
+ :type suite: str
+ :param mirror_location: source input repository
+ :type mirror_location: str
+ """
+ command = (
+ "debootstrap",
+ ("--arch", BOOTSTRAP_ARCHITECTURE),
+ ("--variant", BOOTSTRAP_VARIANT),
+ suite,
+ str(root_path),
+ mirror_location,
+ )
+ ps.run(*command)
diff --git a/rwx/err/__init__.py b/rwx/err/__init__.py
new file mode 100644
index 0000000..6c473e0
--- /dev/null
+++ b/rwx/err/__init__.py
@@ -0,0 +1,7 @@
+"""Handle errors."""
+
+from rwx import Object
+
+
+class Error(Object, Exception):
+ """Parent class for all errors."""
diff --git a/rwx/fs/__init__.py b/rwx/fs/__init__.py
new file mode 100644
index 0000000..8a45288
--- /dev/null
+++ b/rwx/fs/__init__.py
@@ -0,0 +1,159 @@
+"""Operations involving FileSystems."""
+
+import os
+import shutil
+from pathlib import Path
+
+import tomllib
+
+from rwx import ps
+
+CHARSET = "UTF-8"
+
+
+def create_image(file_path: Path, size_bytes: int) -> None:
+ """Create a virtual device image file.
+
+ :param file_path: target image file
+ :type file_path: Path
+ :param size_bytes: virtual volume
+ :type size_bytes: int
+ """
+ ps.run(
+ ("qemu-img", "create"),
+ ("-f", "qcow2"),
+ (str(file_path), str(size_bytes)),
+ )
+
+
+def empty_file(path: Path) -> None:
+ """Empty the file at provided path.
+
+ :param path: target file to empty
+ :type path: Path
+ """
+ write(path, "")
+
+
+def get_mount_uuid(path: Path) -> str:
+ """Return the filesystem UUID of a mountpoint path.
+
+ :param path: mountpoint path
+ :type path: Path
+ :rtype: str
+ """
+ return ps.run_line(
+ "findmnt",
+ "--noheadings",
+ ("--output", "UUID"),
+ str(path),
+ )
+
+
+def get_path_mount(path: Path) -> Path:
+ """Return the mountpoint path of an arbitrary path.
+
+ :param path: arbitrary path
+ :type path: Path
+ :rtype: Path
+ """
+ return Path(
+ ps.run_line(
+ "stat",
+ ("--format", "%m"),
+ str(path),
+ ),
+ )
+
+
+def get_path_uuid(path: Path) -> str:
+ """Return the filesystem UUID of an arbitrary path.
+
+ :param path: arbitrary path
+ :type path: Path
+ :rtype: str
+ """
+ return get_mount_uuid(get_path_mount(path))
+
+
+def make_directory(path: Path) -> None:
+ """Make a directory (and its parents) from a path.
+
+ :param path: directory to create
+ :type path: Path
+ """
+ path.mkdir(exist_ok=True, parents=True)
+
+
+def read_file_bytes(file_path: Path) -> bytes:
+ """Read whole file bytes.
+
+ :param file_path: source input file
+ :type file_path: Path
+ :rtype: bytes
+ """
+ with file_path.open("br") as file_object:
+ return file_object.read()
+
+
+def read_file_dict(file_path: Path, charset: str = CHARSET) -> dict:
+ """Read whole file as toml object.
+
+ :param file_path: source input file
+ :type file_path: Path
+ :param charset: charset to use for decoding input
+ :type charset: str
+ :rtype: dict
+ """
+ text = read_file_text(file_path, charset)
+ return tomllib.loads(text)
+
+
+def read_file_lines(file_path: Path, charset: str = CHARSET) -> list[str]:
+ """Read whole file lines.
+
+ :param file_path: source input file
+ :type file_path: Path
+ :param charset: charset to use for decoding input
+ :type charset: str
+ :rtype: list[str]
+ """
+ return read_file_text(file_path, charset).split(os.linesep)
+
+
+def read_file_text(file_path: Path, charset: str = CHARSET) -> str:
+ """Read whole file text.
+
+ :param file_path: source input file
+ :type file_path: Path
+ :param charset: charset to use for decoding input
+ :type charset: str
+ :rtype: str
+ """
+ return read_file_bytes(file_path).decode(charset)
+
+
+def wipe(path: Path) -> None:
+ """Wipe provided path, whether directory or file.
+
+ :param path: target path
+ :type path: Path
+ """
+ try:
+ path.unlink(missing_ok=True)
+ except IsADirectoryError:
+ shutil.rmtree(path)
+
+
+def write(file_path: Path, text: str, charset: str = CHARSET) -> None:
+ """Write text into a file.
+
+ :param file_path: target file path
+ :type file_path: Path
+ :param text: content to write
+ :type text: str
+ :param charset: charset to use for encoding ouput
+ :type charset: str
+ """
+ with file_path.open(encoding=charset, mode="w") as file_object:
+ file_object.write(text)
diff --git a/rwx/grub/__init__.py b/rwx/grub/__init__.py
new file mode 100644
index 0000000..aa208c3
--- /dev/null
+++ b/rwx/grub/__init__.py
@@ -0,0 +1,54 @@
+"""Wrap GRUB commands."""
+
+from __future__ import annotations
+
+from rwx import cmd, ps
+
+cmd.need("grub-mkimage")
+
+COMPRESSION = "xz"
+ENV_BYTES = 1024
+ENV_COMMENT = "#"
+ENV_HEADER = f"""{ENV_COMMENT} GRUB Environment Block
+"""
+MODULES = {
+ "i386-pc": [
+ ("biosdisk",),
+ ("ntldr",),
+ ],
+}
+
+
+def make_image(
+ image_format: str,
+ image_path: str,
+ modules: list[str],
+ memdisk_path: str,
+ pubkey_path: str | None = None,
+) -> None:
+ """Make a binary bootable image.
+
+ :param image_format: output format (x86_64-efi, i386-pc, arm64-efi)
+ :type image_format: str
+ :param image_path: output file
+ :type image_path: str
+ :param modules: modules to embed
+ :type modules: list[str]
+ :param memdisk_path: archive to include
+ :type memdisk_path: str
+ :param pubkey_path: extra public key to add
+ :type pubkey_path: str | None
+ """
+ args: list[str | tuple[str, ...]] = [
+ "grub-mkimage",
+ ("--compress", COMPRESSION),
+ ("--format", image_format),
+ ("--output", image_path),
+ ("--memdisk", memdisk_path),
+ ]
+ if pubkey_path:
+ args.append(("--pubkey", pubkey_path))
+ args.extend(modules)
+ if extra_modules := MODULES.get(image_format):
+ args.extend(extra_modules)
+ ps.run(*args)
diff --git a/rwx/log/__init__.py b/rwx/log/__init__.py
new file mode 100644
index 0000000..53cfebc
--- /dev/null
+++ b/rwx/log/__init__.py
@@ -0,0 +1,51 @@
+"""Handle logging."""
+
+import logging
+import sys
+
+
+def get_file_logger(name: str) -> logging.Logger:
+ """Return a file logger.
+
+ :param name: arbitrary name
+ :type name: str
+ :rtype: logging.Logger
+ """
+ # formatter
+ items = [
+ "%(name)s: %(asctime)s",
+ "%(levelname)s",
+ "%(filename)s:%(lineno)s",
+ "%(process)d >>> %(message)s",
+ ]
+ template = " | ".join(items)
+ formatter = logging.Formatter(template)
+ # handler
+ out_handler = logging.StreamHandler(stream=sys.stdout)
+ out_handler.setFormatter(formatter)
+ out_handler.setLevel(logging.INFO)
+ # logger
+ logger = logging.getLogger(name)
+ logger.addHandler(out_handler)
+ logger.setLevel(logging.INFO)
+ return logger
+
+
+def get_stream_logger(level: int) -> logging.Logger:
+ """Return a stream logger.
+
+ :param level: filtering level
+ :type level: int
+ :rtype: logging.Logger
+ """
+ # handler
+ out_handler = logging.StreamHandler(stream=sys.stdout)
+ out_handler.setLevel(level)
+ # logger
+ logger = logging.getLogger()
+ logger.addHandler(out_handler)
+ logger.setLevel(level)
+ return logger
+
+
+stream = get_stream_logger(logging.INFO)
diff --git a/rwx/os/__init__.py b/rwx/os/__init__.py
new file mode 100644
index 0000000..27c1748
--- /dev/null
+++ b/rwx/os/__init__.py
@@ -0,0 +1,20 @@
+"""Control Operating Systems."""
+
+from os import sep
+from pathlib import Path
+
+from .abstract import OS
+from .debian import Debian
+
+
+def from_path(path: Path) -> OS:
+ """Initialize from an already existing path.
+
+ :param path: source root directory
+ :type path: Path
+ :rtype: OS
+ """
+ return Debian(path)
+
+
+up = from_path(Path(sep))
diff --git a/rwx/os/abstract.py b/rwx/os/abstract.py
new file mode 100644
index 0000000..98f3f0d
--- /dev/null
+++ b/rwx/os/abstract.py
@@ -0,0 +1,26 @@
+"""Abstract Operating System."""
+
+from abc import ABC, abstractmethod
+from pathlib import Path
+
+from rwx import Object
+
+
+class OS(Object, ABC):
+ """Operating System."""
+
+ def __init__(self, path: Path) -> None:
+ """Set root.
+
+ :param path: root directory
+ :type path: Path
+ """
+ self.root = path
+ self.name = self.get_name()
+
+ @abstractmethod
+ def get_name(self) -> str:
+ """Return mandatory name.
+
+ :rtype: str
+ """
diff --git a/rwx/os/debian.py b/rwx/os/debian.py
new file mode 100644
index 0000000..5cefdaa
--- /dev/null
+++ b/rwx/os/debian.py
@@ -0,0 +1,14 @@
+"""Debian operating system."""
+
+from .abstract import OS
+
+
+class Debian(OS):
+ """Debian operating system."""
+
+ def get_name(self) -> str:
+ """Return name.
+
+ :rtype: str
+ """
+ return "Debian"
diff --git a/rwx/os/pm/__init__.py b/rwx/os/pm/__init__.py
new file mode 100644
index 0000000..785ce74
--- /dev/null
+++ b/rwx/os/pm/__init__.py
@@ -0,0 +1,29 @@
+"""Package Manager."""
+
+from abc import ABC, abstractmethod
+
+from rwx import Object
+from rwx.ps import Command
+
+
+class PM(Object, ABC):
+ """Package Manager."""
+
+ def __init__(self) -> None:
+ """Set commands."""
+ self.clean = self.get_clean_command()
+ self.install = self.get_install_command()
+
+ @abstractmethod
+ def get_clean_command(self) -> Command:
+ """Command to clean packages cache.
+
+ :rtype: Command
+ """
+
+ @abstractmethod
+ def get_install_command(self) -> Command:
+ """Command to install package(s).
+
+ :rtype: Command
+ """
diff --git a/rwx/os/pm/apt.py b/rwx/os/pm/apt.py
new file mode 100644
index 0000000..3e3cb81
--- /dev/null
+++ b/rwx/os/pm/apt.py
@@ -0,0 +1,22 @@
+"""Advanced Package Tool."""
+
+from rwx.os.pm import PM
+from rwx.ps import Command
+
+
+class APT(PM):
+ """Advanced Package Tool."""
+
+ def get_clean_command(self) -> Command:
+ """Return clean command.
+
+ :rtype: Command
+ """
+ return Command()
+
+ def get_install_command(self) -> Command:
+ """Return install command.
+
+ :rtype: Command
+ """
+ return Command()
diff --git a/rwx/prj/__init__.py b/rwx/prj/__init__.py
new file mode 100644
index 0000000..62d91d9
--- /dev/null
+++ b/rwx/prj/__init__.py
@@ -0,0 +1,20 @@
+"""Handle projects."""
+
+from pathlib import Path
+
+from rwx import Object
+
+
+class Project(Object):
+ """Parent class for any type of project."""
+
+ def __init__(self, file: Path) -> None:
+ """Set file, root & name.
+
+ :param file: root reference file
+ :type file: Path
+ """
+ self.raw = file
+ self.file = self.raw.resolve()
+ self.root: Path = self.file.parent
+ self.name: str = self.root.name
diff --git a/rwx/prj/sphinx.py b/rwx/prj/sphinx.py
new file mode 100644
index 0000000..592e6dd
--- /dev/null
+++ b/rwx/prj/sphinx.py
@@ -0,0 +1,39 @@
+"""Project consisting only of a Sphinx documentation."""
+
+from typing import TYPE_CHECKING
+
+from sphinx.cmd.build import build_main
+
+from rwx.fs import wipe
+from rwx.prj import Project
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+class SphinxProject(Project):
+ """Child class for a project based on Sphinx."""
+
+ def build(self) -> None:
+ """Build the project."""
+ output_root: Path = self.root / "out"
+ wipe(output_root)
+ arguments: list[str] = [
+ "-E",
+ "-j",
+ "2",
+ "-b",
+ "html",
+ "-D",
+ f"project={self.name}",
+ "-D",
+ "master_doc=index",
+ "-D",
+ "html_theme=sphinx_rtd_theme",
+ "-c",
+ str(self.root),
+ # "-C",
+ str(self.root / self.name),
+ str(output_root / "web"),
+ ]
+ build_main(arguments)
diff --git a/rwx/ps/__init__.py b/rwx/ps/__init__.py
new file mode 100644
index 0000000..3bef50d
--- /dev/null
+++ b/rwx/ps/__init__.py
@@ -0,0 +1,85 @@
+"""Handle processes."""
+
+from __future__ import annotations
+
+import subprocess
+
+from rwx import Object, txt
+
+
+class Command(Object):
+ """Command to run."""
+
+ def __init__(self, *arguments: str | tuple[str, ...]) -> None:
+ """Set raw & flat arguments.
+
+ :param *arguments: single argument or grouped ones
+ :type *arguments: str | tuple[str, ...]
+ """
+ self.raw = arguments
+ self.flat: list[str] = []
+
+
+def get_tuples_args(*items: str | tuple[str, ...]) -> list[str]:
+ """Turn arguments tuples into an arguments list.
+
+ :param *items: single item or grouped ones
+ :type *items: str | tuple[str, ...]
+ :rtype: list[str]
+ """
+ args: list[str] = []
+ for item in items:
+ match item:
+ case str():
+ args.append(item)
+ case tuple():
+ args.extend(item)
+ return args
+
+
+def run(*items: str | tuple[str, ...]) -> subprocess.CompletedProcess:
+ """Run from a list of arguments tuples.
+
+ :param *items: single item or grouped ones
+ :type *items: str | tuple[str, ...]
+ :rtype: subprocess.CompletedProcess
+ """
+ return subprocess.run(
+ get_tuples_args(*items),
+ capture_output=False,
+ check=True,
+ )
+
+
+def run_line(*items: str | tuple[str, ...], charset: str = txt.CHARSET) -> str:
+ """Run and return output line.
+
+ :param *items: single item or grouped ones
+ :type *items: str | tuple[str, ...]
+ :param charset: charset to use for decoding binary output
+ :type charset: str
+ :rtype: str
+ """
+ line, *_ = run_lines(*items, charset=charset)
+ return line
+
+
+def run_lines(
+ *items: str | tuple[str, ...],
+ charset: str = txt.CHARSET,
+) -> list[str]:
+ """Run and return output lines.
+
+ :param *items: single item or grouped ones
+ :type *items: str | tuple[str, ...]
+ :param charset: charset to use for decoding binary output
+ :type charset: str
+ :rtype: list[str]
+ """
+ process = subprocess.run(
+ get_tuples_args(*items),
+ capture_output=True,
+ check=True,
+ )
+ string = process.stdout.decode(charset)
+ return string.rstrip().splitlines()
diff --git a/rwx/py.typed b/rwx/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/rwx/sw/freetube/__init__.py b/rwx/sw/freetube/__init__.py
new file mode 100644
index 0000000..250b9c3
--- /dev/null
+++ b/rwx/sw/freetube/__init__.py
@@ -0,0 +1 @@
+"""Configure FreeTube."""
diff --git a/rwx/sw/freetube/authors.py b/rwx/sw/freetube/authors.py
new file mode 100644
index 0000000..e7cb913
--- /dev/null
+++ b/rwx/sw/freetube/authors.py
@@ -0,0 +1,31 @@
+"""FreeTube authors."""
+
+from typing import TYPE_CHECKING
+
+from rwx import Object
+
+if TYPE_CHECKING:
+ from .playlists import Playlist
+
+
+class Author(Object):
+ """FreeTube author."""
+
+ def __init__(self, uid: str, name: str) -> None:
+ """Set uid & name.
+
+ :param uid: identifier
+ :type uid: str
+ :param name: label
+ :type name: str
+ """
+ self.uid = uid
+ self.name = name
+ self.playlist: Playlist
+
+ def to_db(self) -> str:
+ """Return non-breakable name.
+
+ :rtype: str
+ """
+ return self.name.replace(" ", chr(0xA0))
diff --git a/rwx/sw/freetube/channels.py b/rwx/sw/freetube/channels.py
new file mode 100644
index 0000000..556dea7
--- /dev/null
+++ b/rwx/sw/freetube/channels.py
@@ -0,0 +1,29 @@
+"""FreeTube channels."""
+
+from rwx import Object
+
+
+class Channel(Object):
+ """FreeTube channel."""
+
+ def __init__(self, uid: str, name: str) -> None:
+ """Set uid & name.
+
+ :param uid: unique identifier
+ :type uid: str
+ :param name: label
+ :type name: str
+ """
+ self.uid = uid
+ self.name = name
+
+ def to_db(self) -> str:
+ """Return identifier as db.
+
+ :rtype: str
+ """
+ return f"""\
+{{\
+"id":"{self.uid}"\
+}}\
+"""
diff --git a/rwx/sw/freetube/db.py b/rwx/sw/freetube/db.py
new file mode 100644
index 0000000..e3eb31e
--- /dev/null
+++ b/rwx/sw/freetube/db.py
@@ -0,0 +1,21 @@
+"""Output FreeTube db."""
+
+
+def to_db(value: object) -> str:
+ """Render value as string.
+
+ :param value: value to render
+ :type value: object
+ :rtype: str
+ """
+ match value:
+ case bool():
+ text = str(value).lower()
+ case dict():
+ sub = ",".join([f'"{i}":{to_db(v)}' for i, v in value.items()])
+ text = f"{{{sub}}}"
+ case float() | str():
+ text = f'"{value}"'
+ case _:
+ text = str(value)
+ return text
diff --git a/rwx/sw/freetube/languages.py b/rwx/sw/freetube/languages.py
new file mode 100644
index 0000000..beeb173
--- /dev/null
+++ b/rwx/sw/freetube/languages.py
@@ -0,0 +1,24 @@
+"""FreeTube languages."""
+
+from typing import TYPE_CHECKING
+
+from rwx import Object
+
+if TYPE_CHECKING:
+ from .playlists import Playlist
+
+
+class Language(Object):
+ """FreeTube language."""
+
+ def __init__(self, uid: str, name: str) -> None:
+ """Set uid & name.
+
+ :param uid: identifier
+ :type uid: str
+ :param name: label
+ :type name: str
+ """
+ self.uid = uid
+ self.name = name
+ self.playlist: Playlist
diff --git a/rwx/sw/freetube/playlists.py b/rwx/sw/freetube/playlists.py
new file mode 100644
index 0000000..f4b17de
--- /dev/null
+++ b/rwx/sw/freetube/playlists.py
@@ -0,0 +1,47 @@
+"""FreeTube playlists."""
+
+from rwx import Object
+
+from .videos import Video
+
+
+class Playlist(Object):
+ """FreeTube playlist."""
+
+ def __init__(self, uid: str, name: str) -> None:
+ """Set uid & name.
+
+ :param uid: identifier
+ :type uid: str
+ :param name: label
+ :type name: str
+ """
+ self.uid = uid
+ self.name = name
+ self.videos: list[Video] = []
+
+ def add(self, video: Video) -> None:
+ """Add video.
+
+ :param video: video to add
+ :type video: Video
+ """
+ self.videos.append(video)
+
+ def to_db(self) -> str:
+ """Return identifier, name & videos.
+
+ :rtype: str
+ """
+ videos = ",".join([video.to_db() for video in self.videos])
+ return f"""\
+{{\
+"_id":"{self.uid}"\
+,\
+"playlistName":"{self.name}"\
+,\
+"protected":true\
+,\
+"videos":[{videos}]\
+}}\
+"""
diff --git a/rwx/sw/freetube/profiles.py b/rwx/sw/freetube/profiles.py
new file mode 100644
index 0000000..e164296
--- /dev/null
+++ b/rwx/sw/freetube/profiles.py
@@ -0,0 +1,45 @@
+"""FreeTube profiles."""
+
+from rwx import Object
+
+from .channels import Channel
+
+
+class Profile(Object):
+ """FreeTube profile."""
+
+ def __init__(self, uid: str, name: str) -> None:
+ """Set uid & name.
+
+ :param uid: unique identifier
+ :type uid: str
+ :param name: label
+ :type name: str
+ """
+ self.id = uid
+ self.name = name
+ self.channels: list[Channel] = []
+
+ def add(self, channel: Channel) -> None:
+ """Add channel.
+
+ :param channel: channel to add
+ :type channel: Channel
+ """
+ self.channels.append(channel)
+
+ def to_db(self) -> str:
+ """Return identifier, name & channels.
+
+ :rtype: str
+ """
+ channels = ",".join([channel.to_db() for channel in self.channels])
+ return f"""\
+{{\
+"_id":"{self.id}"\
+,\
+"name":"{self.name}"\
+,\
+"subscriptions":[{channels}]\
+}}\
+"""
diff --git a/rwx/sw/freetube/settings.py b/rwx/sw/freetube/settings.py
new file mode 100644
index 0000000..621454b
--- /dev/null
+++ b/rwx/sw/freetube/settings.py
@@ -0,0 +1,33 @@
+"""FreeTube settings."""
+
+from rwx import Object
+
+from .db import to_db
+
+
+class Setting(Object):
+ """FreeTube setting."""
+
+ def __init__(self, uid: str, value: object) -> None:
+ """Set uid & value.
+
+ :param uid: unique identifier
+ :type uid: str
+ :param value: value
+ :type value: object
+ """
+ self.uid = uid
+ self.value = value
+
+ def to_db(self) -> str:
+ """Return uid & value as db string.
+
+ :rtype: str
+ """
+ return f"""\
+{{\
+"_id":"{self.uid}"\
+,\
+"value":{to_db(self.value)}\
+}}\
+"""
diff --git a/rwx/sw/freetube/videos.py b/rwx/sw/freetube/videos.py
new file mode 100644
index 0000000..6003c75
--- /dev/null
+++ b/rwx/sw/freetube/videos.py
@@ -0,0 +1,33 @@
+"""FreeTube videos."""
+
+from rwx import Object
+
+
+class Video(Object):
+ """FreeTube video."""
+
+ def __init__(self, uid: str, name: str) -> None:
+ """Set id & name.
+
+ :param uid: identifier
+ :type uid: str
+ :param name: label
+ :type name: str
+ """
+ self.uid = uid
+ self.name = name
+
+ def to_db(self) -> str:
+ """Return identifier, zero length & title.
+
+ :rtype: str
+ """
+ return f"""\
+{{\
+"videoId":"{self.uid}"\
+,\
+"lengthSeconds":0\
+,\
+"title":"{self.name}"\
+}}\
+"""
diff --git a/rwx/txt/__init__.py b/rwx/txt/__init__.py
new file mode 100644
index 0000000..be2f273
--- /dev/null
+++ b/rwx/txt/__init__.py
@@ -0,0 +1,3 @@
+"""Handle text."""
+
+CHARSET = "UTF-8"
diff --git a/sh/alias/apt.sh b/sh/alias/apt.sh
new file mode 100644
index 0000000..0f3294e
--- /dev/null
+++ b/sh/alias/apt.sh
@@ -0,0 +1,95 @@
+# show package information
+acl() { a__apt_cache_list "${@}"; }
+a__apt_cache_list() {
+ apt-cache \
+ show \
+ "${@}"
+}
+
+# package versions policy
+acp() { a__apt_cache_policy "${@}"; }
+a__apt_cache_policy() {
+ apt-cache \
+ policy \
+ "${@}"
+}
+
+# search package
+acs() { a__apt_cache_search "${@}"; }
+a__apt_cache_search() {
+ apt-cache \
+ search \
+ "${@}"
+}
+
+#
+agap() { a__apt_get_auto_purge "${@}"; }
+a__apt_get_auto_purge() {
+ apt-get \
+ autopurge \
+ "${@}"
+}
+
+#
+agar() { a__apt_get_auto_remove "${@}"; }
+a__apt_get_auto_remove() {
+ apt-get \
+ autoremove \
+ "${@}"
+}
+
+# clean packages cache
+agc() { a__apt_get_clean "${@}"; }
+a__apt_get_clean() {
+ apt-get \
+ clean \
+ "${@}"
+}
+
+# upgrade allowing package installation or removal
+agfu() { a__apt_get_full_upgrade "${@}"; }
+a__apt_get_full_upgrade() {
+ apt-get \
+ full-upgrade \
+ "${@}"
+}
+
+# install packages
+agi() { a__apt_get_install "${@}"; }
+a__apt_get_install() {
+ apt-get \
+ install \
+ "${@}"
+}
+
+#
+agp() { a__apt_get_purge "${@}"; }
+a__apt_get_purge() {
+ apt-get \
+ purge \
+ "${@}"
+}
+
+#
+agr() { a__apt_get_remove "${@}"; }
+a__apt_get_remove() {
+ apt-get \
+ remove \
+ "${@}"
+}
+
+# update packages catalog
+agud() { a__apt_get_up_date "${@}"; }
+a__apt_get_up_date() {
+ apt-get \
+ update \
+ "${@}"
+}
+
+# upgrade forbidding package installation or removal
+agug() { a__apt_get_up_grade "${@}"; }
+a__apt_get_up_grade() {
+ apt-get \
+ upgrade \
+ "${@}"
+}
diff --git a/sh/alias/batcat.sh b/sh/alias/batcat.sh
new file mode 100644
index 0000000..99d8373
--- /dev/null
+++ b/sh/alias/batcat.sh
@@ -0,0 +1,5 @@
+b() { a__bat "${@}"; }
+a__bat() {
+ batcat \
+ "${@}"
+}
diff --git a/sh/alias/btrfs.sh b/sh/alias/btrfs.sh
new file mode 100644
index 0000000..0e4ba62
--- /dev/null
+++ b/sh/alias/btrfs.sh
@@ -0,0 +1,73 @@
+bfdf() { a__btrfs_filesystem_d_f "${@}"; }
+a__btrfs_filesystem_d_f() {
+ btrfs \
+ filesystem \
+ df \
+ "${@}"
+}
+
+bfdu() { a__btrfs_filesystem_d_u "${@}"; }
+a__btrfs_filesystem_d_u() {
+ btrfs \
+ filesystem \
+ du \
+ --summarize \
+ "${@}"
+}
+
+bfu() { a__btrfs_filesystem_usage "${@}"; }
+a__btrfs_filesystem_usage() {
+ btrfs \
+ filesystem \
+ usage \
+ "${@}"
+}
+
+bpg() { a__btrfs_property_get "${@}"; }
+a__btrfs_property_get() {
+ btrfs \
+ property \
+ get \
+ "${@}"
+}
+
+bsc() { a__btrfs_subvolume_create "${@}"; }
+a__btrfs_subvolume_create() {
+ btrfs \
+ subvolume \
+ create \
+ "${@}"
+}
+
+bsd() { a__btrfs_subvolume_delete "${@}"; }
+a__btrfs_subvolume_delete() {
+ btrfs \
+ subvolume \
+ delete \
+ "${@}"
+}
+
+bsl() { a__btrfs_subvolume_list "${@}"; }
+a__btrfs_subvolume_list() {
+ if [ -n "${1}" ]; then
+ btrfs subvolume list "${1}" |
+ cut --delimiter " " --fields 9 |
+ sort
+ fi
+}
+
+bss() { a__btrfs_subvolume_snapshot "${@}"; }
+a__btrfs_subvolume_snapshot() {
+ btrfs \
+ subvolume \
+ snapshot \
+ "${@}"
+}
+
+bssr() { a__btrfs_subvolume_snapshot_r "${@}"; }
+a__btrfs_subvolume_snapshot_r() {
+ btrfs \
+ subvolume \
+ snapshot -r \
+ "${@}"
+}
diff --git a/sh/alias/byobu.sh b/sh/alias/byobu.sh
new file mode 100644
index 0000000..0f5336e
--- /dev/null
+++ b/sh/alias/byobu.sh
@@ -0,0 +1,27 @@
+bb() { a__byo_bu "${@}"; }
+a__byo_bu() {
+ byobu \
+ "${@}"
+}
+
+bba() { a__byo_bu_attach "${@}"; }
+a__byo_bu_attach() {
+ byobu \
+ attach-session \
+ "${@}"
+}
+
+bbl() { a__byo_bu_ls "${@}"; }
+a__byo_bu_ls() {
+ byobu \
+ ls \
+ "${@}"
+}
+
+bbnd() { a__byo_bu_new_detach "${@}"; }
+a__byo_bu_new_detach() {
+ byobu \
+ new-session \
+ -d \
+ "${@}"
+}
diff --git a/sh/alias/chmod.sh b/sh/alias/chmod.sh
new file mode 100644
index 0000000..57ec74e
--- /dev/null
+++ b/sh/alias/chmod.sh
@@ -0,0 +1,15 @@
+# change mode to directory
+cmd() { a__change_mode_directory "${@}"; }
+a__change_mode_directory() {
+ chmod \
+ "755" \
+ "${@}"
+}
+
+# change mode to file
+cmf() { a__change_mode_file "${@}"; }
+a__change_mode_file() {
+ chmod \
+ "644" \
+ "${@}"
+}
diff --git a/sh/alias/chown.sh b/sh/alias/chown.sh
new file mode 100644
index 0000000..f4fa865
--- /dev/null
+++ b/sh/alias/chown.sh
@@ -0,0 +1,15 @@
+# change owner to root
+cor() { a__change_owner_root "${@}"; }
+a__change_owner_root() {
+ chown \
+ "0:0" \
+ "${@}"
+}
+
+# change owner to user
+cou() { a__change_owner_user "${@}"; }
+a__change_owner_user() {
+ chown \
+ "1000:1000" \
+ "${@}"
+}
diff --git a/sh/alias/clear.sh b/sh/alias/clear.sh
new file mode 100644
index 0000000..ae32b30
--- /dev/null
+++ b/sh/alias/clear.sh
@@ -0,0 +1,6 @@
+# clear terminal
+c() { a__clear "${@}"; }
+a__clear() {
+ clear \
+ "${@}"
+}
diff --git a/sh/alias/cp.sh b/sh/alias/cp.sh
new file mode 100644
index 0000000..c9e7d6b
--- /dev/null
+++ b/sh/alias/cp.sh
@@ -0,0 +1,7 @@
+# copy interactively
+cpi() { a__co_py_interactive "${@}"; }
+a__co_py_interactive() {
+ cp \
+ --interactive \
+ "${@}"
+}
diff --git a/sh/alias/emacs.sh b/sh/alias/emacs.sh
new file mode 100644
index 0000000..8e26878
--- /dev/null
+++ b/sh/alias/emacs.sh
@@ -0,0 +1,5 @@
+em() { a__e_macs "${@}"; }
+a__e_macs() {
+ emacs \
+ "${@}"
+}
diff --git a/sh/alias/evince.sh b/sh/alias/evince.sh
new file mode 100644
index 0000000..25ac069
--- /dev/null
+++ b/sh/alias/evince.sh
@@ -0,0 +1,5 @@
+ev() { a__e_vince "${@}"; }
+a__e_vince() {
+ evince \
+ "${@}"
+}
diff --git a/sh/alias/git.sh b/sh/alias/git.sh
new file mode 100644
index 0000000..cadd10b
--- /dev/null
+++ b/sh/alias/git.sh
@@ -0,0 +1,548 @@
+RWX_GIT_LOG_FORMAT="\
+%C(auto)%h%d
+S %C(red)%GS
+A %C(green)%an %ae
+ %C(green)%ai
+C %C(blue)%cn %ce
+ %C(blue)%ci
+%B"
+
+# add to index
+ga() { a__git_add "${@}"; }
+a__git_add() {
+ git \
+ add \
+ "${@}"
+}
+
+# add all to index
+gaa() { a__git_add_all "${@}"; }
+a__git_add_all() {
+ git \
+ add \
+ --all \
+ "${@}"
+}
+
+# add parts of all to index
+gaap() { a__git_add_all_patch "${@}"; }
+a__git_add_all_patch() {
+ git \
+ add \
+ --all \
+ --patch \
+ "${@}"
+}
+
+# add parts to index
+gap() { a__git_add_patch "${@}"; }
+a__git_add_patch() {
+ git \
+ add \
+ --patch \
+ "${@}"
+}
+
+# create a branch
+gb() { a__git_branch "${@}"; }
+a__git_branch() {
+ git \
+ branch \
+ "${@}"
+}
+
+# delete a branch
+gbd() { a__git_branch_delete "${@}"; }
+a__git_branch_delete() {
+ git \
+ branch \
+ --delete \
+ "${@}"
+}
+
+# force a branch deletion
+gbdf() { a__git_branch_delete_force "${@}"; }
+a__git_branch_delete_force() {
+ git \
+ branch \
+ --delete \
+ --force \
+ "${@}"
+}
+
+# list branches
+gbl() { a__git_branch_list "${@}"; }
+a__git_branch_list() {
+ git \
+ branch \
+ --all \
+ --list \
+ --verbose \
+ --verbose \
+ "${@}"
+}
+
+# set the link to a remote branch from a local branch
+gbsu() { a__git_branch_set_upstream "${@}"; }
+a__git_branch_set_upstream() {
+ git \
+ branch \
+ --set-upstream-to \
+ "${@}"
+}
+
+# switch to a branch or checkout file(s) from a commit
+gc() { a__git_checkout "${@}"; }
+a__git_checkout() {
+ git \
+ checkout \
+ "${@}"
+}
+
+# checkout an orphan branch
+gco() { a__git_checkout_orphan "${@}"; }
+a__git_checkout_orphan() {
+ git \
+ checkout \
+ --orphan \
+ "${@}"
+}
+
+# pick a commit
+gcp() { a__git_cherry_pick "${@}"; }
+a__git_cherry_pick() {
+ git \
+ cherry-pick \
+ "${@}"
+}
+
+# abort the commit pick
+gcpa() { a__git_cherry_pick_abort "${@}"; }
+a__git_cherry_pick_abort() {
+ git \
+ cherry-pick \
+ --abort \
+ "${@}"
+}
+
+# continue the commit pick
+gcpc() { a__git_cherry_pick_continue "${@}"; }
+a__git_cherry_pick_continue() {
+ git \
+ cherry-pick \
+ --continue \
+ "${@}"
+}
+
+# clean untracked files
+gcf() { a__git_clean_force "${@}"; }
+a__git_clean_force() {
+ git \
+ clean \
+ -d \
+ --force \
+ "${@}"
+}
+
+# redo the last commit with a different message
+gcam() { a__git_commit_amend_message "${@}"; }
+a__git_commit_amend_message() {
+ git \
+ commit \
+ --amend \
+ --message \
+ "${@}"
+}
+
+# make a root commit
+gcem() { a__git_commit_empty_message "${@}"; }
+a__git_commit_empty_message() {
+ git \
+ commit \
+ --allow-empty \
+ --allow-empty-message \
+ --message \
+ "${@}"
+}
+
+# commit the index
+gcm() { a__git_commit_message "${@}"; }
+a__git_commit_message() {
+ git \
+ commit \
+ --message \
+ "${@}"
+}
+
+# configure the user email
+gcue() { a__git_config_user_email "${@}"; }
+a__git_config_user_email() {
+ git \
+ config \
+ "user.email" \
+ "${@}"
+}
+
+# configure the user name
+gcun() { a__git_config_user_name "${@}"; }
+a__git_config_user_name() {
+ git \
+ config \
+ "user.name" \
+ "${@}"
+}
+
+# differences from last or between commits
+gd() { a__git_diff "${@}"; }
+a__git_diff() {
+ git \
+ diff \
+ "${@}"
+}
+
+# display what is indexed in cache
+gdc() { a__git_diff_cached "${@}"; }
+a__git_diff_cached() {
+ git \
+ diff \
+ --cached \
+ "${@}"
+}
+
+# indexed character-level differences
+gdcw() { a__git_diff_cached_word "${@}"; }
+a__git_diff_cached_word() {
+ git \
+ diff \
+ --cached \
+ --word-diff-regex "." \
+ "${@}"
+}
+
+# differences via external tool
+gdt() { a__git_diff_tool "${@}"; }
+a__git_diff_tool() {
+ git \
+ difftool \
+ --dir-diff \
+ "${@}"
+}
+
+# character-level differences
+gdw() { a__git_diff_word "${@}"; }
+a__git_diff_word() {
+ git \
+ diff \
+ --word-diff-regex "." \
+ "${@}"
+}
+
+# fetch from the remote repository
+gf() { a__git_fetch "${@}"; }
+a__git_fetch() {
+ rwx_gpg_agent_update &&
+ git \
+ fetch \
+ --tags \
+ --verbose \
+ "${@}"
+}
+
+# fetch from remote repository and prune local orphan branches
+gfp() { a__git_fetch_prune "${@}"; }
+a__git_fetch_prune() {
+ a__git_fetch \
+ --prune \
+ "${@}"
+}
+
+# garbage collect all orphan commits
+ggc() { a__git_garbage_collect "${@}"; }
+a__git_garbage_collect() {
+ git \
+ reflog \
+ expire \
+ --all \
+ --expire "all" &&
+ git \
+ gc \
+ --aggressive \
+ --prune="now"
+}
+
+# initialize a new repository
+gi() { a__git_init "${@}"; }
+a__git_init() {
+ git \
+ init \
+ "${@}"
+}
+
+# initialize a new bare repository
+gib() { a__git_init_bare "${@}"; }
+a__git_init_bare() {
+ git \
+ init \
+ --bare \
+ "${@}"
+}
+
+# log history
+gl() { a__git_log "${@}"; }
+a__git_log() {
+ git \
+ log \
+ --abbrev=8 \
+ --abbrev-commit \
+ --format="${RWX_GIT_LOG_FORMAT}" \
+ --graph \
+ "${@}"
+}
+
+# log all history
+gla() { a__git_log_all "${@}"; }
+a__git_log_all() {
+ a__git_log \
+ --all \
+ "${@}"
+}
+
+# log all history with patches
+glap() { a__git_log_all_patch "${@}"; }
+a__git_log_all_patch() {
+ a__git_log \
+ --all \
+ --patch \
+ "${@}"
+}
+
+# log history with patches
+glp() { a__git_log_patch "${@}"; }
+a__git_log_patch() {
+ a__git_log \
+ --patch \
+ "${@}"
+}
+
+# fast-forward merge to remote branch
+gm() { a__git_merge "${@}"; }
+a__git_merge() {
+ git \
+ merge \
+ --ff-only \
+ "${@}"
+}
+
+# abort the current merge commit
+gma() { a__git_merge_abort "${@}"; }
+a__git_merge_abort() {
+ git \
+ merge \
+ --abort \
+ "${@}"
+}
+
+# do a merge commit
+gmc() { a__git_merge_commit "${@}"; }
+a__git_merge_commit() {
+ git \
+ merge \
+ --no-ff \
+ --message \
+ "${@}"
+}
+
+# squash a branch and index its modifications
+gms() { a__git_merge_squash "${@}"; }
+a__git_merge_squash() {
+ git \
+ merge \
+ --squash \
+ "${@}"
+}
+
+# merge via external tool
+gmt() { a__git_merge_tool "${@}"; }
+a__git_merge_tool() {
+ git \
+ mergetool \
+ "${@}"
+}
+
+# push to the remote repository
+gp() { a__git_push "${@}"; }
+a__git_push() {
+ rwx_gpg_agent_update &&
+ git \
+ push \
+ --tags \
+ --verbose \
+ "${@}"
+}
+
+# delete from the remote repository
+gpd() { a__git_push_delete "${@}"; }
+a__git_push_delete() {
+ git \
+ push \
+ --delete \
+ "${@}"
+}
+
+# force the push to the remote repository
+gpf() { a__git_push_force "${@}"; }
+a__git_push_force() {
+ a__git_push \
+ --force \
+ "${@}"
+}
+
+# rebase current branch onto another
+grb() { a__git_re_base "${@}"; }
+a__git_re_base() {
+ git \
+ rebase \
+ "${@}"
+}
+
+# abort current rebase
+grba() { a__git_re_base_abort "${@}"; }
+a__git_re_base_abort() {
+ git \
+ rebase \
+ --abort \
+ "${@}"
+}
+
+# continue current rebase
+grbc() { a__git_re_base_continue "${@}"; }
+a__git_re_base_continue() {
+ git \
+ rebase \
+ --continue \
+ "${@}"
+}
+
+# force rebase without fast-forward
+grbf() { a__git_re_base_force "${@}"; }
+a__git_re_base_force() {
+ git \
+ rebase \
+ --force-rebase \
+ "${@}"
+}
+
+# rebase interactively
+grbi() { a__git_re_base_interactive "${@}"; }
+a__git_re_base_interactive() {
+ git \
+ rebase \
+ --interactive \
+ "${@}"
+}
+
+# add a new remote repository
+grma() { a__git_re_mote_add "${@}"; }
+a__git_re_mote_add() {
+ git \
+ remote \
+ add \
+ "${@}"
+}
+
+# list remote repositories
+grml() { a__git_re_mote_list "${@}"; }
+a__git_re_mote_list() {
+ git \
+ remote \
+ --verbose \
+ "${@}"
+}
+
+# set the location of a remote repository
+grmsu() { a__git_re_mote_set_upstream "${@}"; }
+a__git_re_mote_set_upstream() {
+ git \
+ remote \
+ set-url \
+ "${@}"
+}
+
+# show connection to a remote repository
+grms() { a__git_re_mote_show "${@}"; }
+a__git_re_mote_show() {
+ git \
+ remote \
+ show \
+ "${@}"
+}
+
+# remove and add removal to index
+grm() { a__git_re_move "${@}"; }
+a__git_re_move() {
+ git \
+ rm \
+ "${@}"
+}
+
+# remove file(s) from index or move current branch pointer
+grs() { a__git_re_set "${@}"; }
+a__git_re_set() {
+ git \
+ reset \
+ "${@}"
+}
+
+# wipe modifications or reset current branch to another commit
+grsh() { a__git_re_set_hard "${@}"; }
+a__git_re_set_hard() {
+ git \
+ reset \
+ --hard \
+ "${@}"
+}
+
+# show a commit
+gsc() { a__git_show_commit "${@}"; }
+a__git_show_commit() {
+ git \
+ show \
+ "${@}"
+}
+
+# current state of repository
+gs() { a__git_status "${@}"; }
+a__git_status() {
+ git \
+ status \
+ --untracked-files="all" \
+ "${@}"
+}
+
+# tag a commit
+gt() { a__git_tag "${@}"; }
+a__git_tag() {
+ git \
+ tag \
+ "${@}"
+}
+
+# delete a tag
+gtd() { a__git_tag_delete "${@}"; }
+a__git_tag_delete() {
+ git \
+ tag \
+ --delete \
+ "${@}"
+}
+
+# update head ref
+gurh() { a__git_update_ref_head "${@}"; }
+a__git_update_ref_head() {
+ if [ -n "${2}" ]; then
+ git \
+ update-ref \
+ "refs/heads/${1}" \
+ "${2}"
+ fi
+}
diff --git a/sh/alias/gpg.sh b/sh/alias/gpg.sh
new file mode 100644
index 0000000..eb19651
--- /dev/null
+++ b/sh/alias/gpg.sh
@@ -0,0 +1,12 @@
+# turn gpg agent off
+gak() { a__gpg_agent_kill "${@}"; }
+a__gpg_agent_kill() {
+ gpgconf \
+ --kill "gpg-agent"
+}
+
+# bind gpg agent to current tty
+gau() { a__gpg_agent_update "${@}"; }
+a__gpg_agent_update() {
+ rwx_gpg_agent_update
+}
diff --git a/sh/alias/grep.sh b/sh/alias/grep.sh
new file mode 100644
index 0000000..5b00ed7
--- /dev/null
+++ b/sh/alias/grep.sh
@@ -0,0 +1,9 @@
+# grep from current directory with regex
+g() { a__grep "${@}"; }
+a__grep() {
+ grep \
+ --directories "recurse" \
+ --line-number \
+ --regexp \
+ "${@}"
+}
diff --git a/sh/alias/kill.sh b/sh/alias/kill.sh
new file mode 100644
index 0000000..718a307
--- /dev/null
+++ b/sh/alias/kill.sh
@@ -0,0 +1,14 @@
+# kill a process by id
+k() { a__kill "${@}"; }
+a__kill() {
+ kill \
+ "${@}"
+}
+
+# force kill a process by id
+kf() { a__kill_force "${@}"; }
+a__kill_force() {
+ kill \
+ -9 \
+ "${@}"
+}
diff --git a/sh/alias/killall.sh b/sh/alias/killall.sh
new file mode 100644
index 0000000..6658065
--- /dev/null
+++ b/sh/alias/killall.sh
@@ -0,0 +1,14 @@
+# kill all instances of a process by name
+ka() { a__kill_all "${@}"; }
+a__kill_all() {
+ killall \
+ "${@}"
+}
+
+# force kill all instances of a process by name
+kaf() { a__kill_all_force "${@}"; }
+a__kill_all_force() {
+ killall \
+ -9 \
+ "${@}"
+}
diff --git a/sh/alias/ls.sh b/sh/alias/ls.sh
new file mode 100644
index 0000000..ad5a809
--- /dev/null
+++ b/sh/alias/ls.sh
@@ -0,0 +1,32 @@
+export LS_COLORS="\
+di=0;94\
+"
+
+# list current directory’s entries
+l() { a__ls "${@}"; }
+a__ls() {
+ ls \
+ --all \
+ --color \
+ -l \
+ -p \
+ --time-style "+" \
+ "${@}"
+}
+
+# list timestamps
+lt() { a__ls_time "${@}"; }
+a__ls_time() {
+ a__ls \
+ --time-style "+%Y%m%d-%H%M%S%-:::z" \
+ "${@}"
+}
+
+# list timestamps recent last
+ltr() { a__ls_time_reverse "${@}"; }
+a__ls_time_reverse() {
+ a__ls_time \
+ --reverse \
+ -t \
+ "${@}"
+}
diff --git a/sh/alias/lsblk.sh b/sh/alias/lsblk.sh
new file mode 100644
index 0000000..43dffc6
--- /dev/null
+++ b/sh/alias/lsblk.sh
@@ -0,0 +1,31 @@
+# list block devices
+lb() { a__list_block "${@}"; }
+a__list_block() {
+ a__list_block_output \
+ "SIZE" \
+ "TYPE" \
+ "FSTYPE" \
+ "LABEL" \
+ "MOUNTPOINTS" \
+ "${@}"
+}
+
+# base arguments
+lbne() { a__list_block_no_empty "${@}"; }
+a__list_block_no_empty() {
+ lsblk \
+ --noempty \
+ "${@}"
+}
+
+# output arguments
+lbo() { a__list_block_output "${@}"; }
+a__list_block_output() {
+ local argument
+ local arguments="NAME"
+ for argument in "${@}"; do
+ arguments="${arguments},${argument}"
+ done
+ a__list_block_no_empty \
+ --output "${arguments}"
+}
diff --git a/sh/alias/mkdir.sh b/sh/alias/mkdir.sh
new file mode 100644
index 0000000..bebc665
--- /dev/null
+++ b/sh/alias/mkdir.sh
@@ -0,0 +1,14 @@
+# make a directory
+md() { a__make_directory "${@}"; }
+a__make_directory() {
+ mkdir \
+ "${@}"
+}
+
+# make a directory after making its parents
+mdp() { a__make_directory_parents "${@}"; }
+a__make_directory_parents() {
+ mkdir \
+ --parents \
+ "${@}"
+}
diff --git a/sh/alias/mount.sh b/sh/alias/mount.sh
new file mode 100644
index 0000000..535910e
--- /dev/null
+++ b/sh/alias/mount.sh
@@ -0,0 +1,5 @@
+m() { a__mount "${@}"; }
+a__mount() {
+ mount \
+ "${@}"
+}
diff --git a/sh/alias/mv.sh b/sh/alias/mv.sh
new file mode 100644
index 0000000..0630042
--- /dev/null
+++ b/sh/alias/mv.sh
@@ -0,0 +1,7 @@
+# move interactively
+mvi() { a__mo_ve_interactive "${@}"; }
+a__mo_ve_interactive() {
+ mv \
+ --interactive \
+ "${@}"
+}
diff --git a/sh/alias/nano.sh b/sh/alias/nano.sh
new file mode 100644
index 0000000..7570cda
--- /dev/null
+++ b/sh/alias/nano.sh
@@ -0,0 +1,5 @@
+nn() { a__na_no "${@}"; }
+a__na_no() {
+ nano \
+ "${@}"
+}
diff --git a/sh/alias/newsboat.sh b/sh/alias/newsboat.sh
new file mode 100644
index 0000000..6c08c90
--- /dev/null
+++ b/sh/alias/newsboat.sh
@@ -0,0 +1,5 @@
+nb() { a__news_boat "${@}"; }
+a__news_boat() {
+ newsboat \
+ "${@}"
+}
diff --git a/sh/alias/overlay.sh b/sh/alias/overlay.sh
new file mode 100644
index 0000000..6357655
--- /dev/null
+++ b/sh/alias/overlay.sh
@@ -0,0 +1,140 @@
+obm() { a__overlay_bind_mount "${@}"; }
+a__overlay_bind_mount() {
+ local directory
+ for directory in "dev" "dev/pts" "proc" "sys"; do
+ if ! mount --bind "/${directory}" "overlay/mount/${directory}"; then
+ rwx_log_error "Unable to bind mount directory: ${directory}"
+ return 1
+ fi
+ done
+}
+
+obu() { a__overlay_bind_unmount "${@}"; }
+a__overlay_bind_unmount() {
+ local directory
+ for directory in "sys" "proc" "dev/pts" "dev"; do
+ if ! umount --lazy "overlay/mount/${directory}"; then
+ rwx_log_error "Unable to bind unmount directory: ${directory}"
+ return 1
+ fi
+ done
+}
+
+ocr() { a__overlay_command_root "${@}"; }
+a__overlay_command_root() {
+ chroot \
+ "overlay/mount" "${@}"
+}
+
+ocu() { a__overlay_command_user "${@}"; }
+a__overlay_command_user() {
+ chroot \
+ --userspec "1000:1000" \
+ "overlay/mount" "${@}"
+}
+
+omm() { a__overlay_mirror_mount "${@}"; }
+a__overlay_mirror_mount() {
+ mount --make-rslave --rbind "/deb" "overlay/mount/deb"
+}
+
+omu() { a__overlay_mirror_unmount "${@}"; }
+a__overlay_mirror_unmount() {
+ umount --recursive "overlay/mount/deb"
+}
+
+orm() { a__overlay_root_mount "${@}"; }
+a__overlay_root_mount() {
+ local root="${1}"
+ if [ -z "${root}" ]; then
+ rwx_log_error "No root target directory"
+ return 1
+ fi
+ root="$(realpath "${root}")"
+ if ! mkdir "overlay"; then
+ rwx_log_error "Unable to make overlay directory"
+ return 2
+ fi
+ (
+ if ! cd "overlay"; then
+ rwx_log_error "Unable to move into overlay directory"
+ return 3
+ fi
+ local directory
+ for directory in "lower" "upper" "work" "mount"; do
+ if ! mkdir --parents "${directory}"; then
+ rwx_log_error "Unable to make directory: ${directory}"
+ return 4
+ fi
+ done
+ local file="${root}/filesystem.squashfs"
+ if ! mount "${file}" "lower"; then
+ rwx_log_error "Unable to lower mount: ${file}"
+ return 5
+ fi
+ if ! mount \
+ -o "lowerdir=lower,upperdir=upper,workdir=work" \
+ -t "overlay" \
+ "overlay" "mount"; then
+ rwx_log_error "Unable to overlay mount"
+ return 6
+ fi
+ )
+}
+
+ors() { a__overlay_root_squash "${@}"; }
+a__overlay_root_squash() {
+ local directory="${1}"
+ local file
+ local level="${2}"
+ if [ -n "${directory}" ]; then
+ if mkdir "${directory}"; then
+ [ -n "${level}" ] || level="18"
+ for file in "vmlinuz" "initrd.img"; do
+ cp "overlay/mount/${file}" "${directory}"
+ done
+ mksquashfs \
+ "overlay/mount" "${directory}/filesystem.squashfs" \
+ -noappend \
+ -comp "zstd" -Xcompression-level "${level}"
+ chown --recursive 1000:1000 "${directory}"
+ fi
+ fi
+}
+
+oru() { a__overlay_root_unmount "${@}"; }
+a__overlay_root_unmount() {
+ (
+ if ! cd "overlay"; then
+ rwx_log_error "Unable to move into overlay directory"
+ return 1
+ fi
+ if ! umount "mount"; then
+ rwx_log_error "Unable to unmount mount directory"
+ return 2
+ fi
+ if ! rmdir "mount"; then
+ rwx_log_error "Unable to remove mount directory"
+ return 3
+ fi
+ local directory
+ for directory in "upper" "work"; do
+ if ! rm --force --recursive "${directory}"; then
+ rwx_log_error "Unable to remove directory: ${directory}"
+ return 4
+ fi
+ done
+ if ! umount "lower"; then
+ rwx_log_error "Unable to unmount lower directory"
+ return 5
+ fi
+ if ! rmdir "lower"; then
+ rwx_log_error "Unable to remove lower directory"
+ return 6
+ fi
+ )
+ if ! rmdir "overlay"; then
+ rwx_log_error "Unable to remove overlay directory"
+ return 7
+ fi
+}
diff --git a/sh/alias/pass.sh b/sh/alias/pass.sh
new file mode 100644
index 0000000..66cad55
--- /dev/null
+++ b/sh/alias/pass.sh
@@ -0,0 +1,14 @@
+# display pass entry’s content
+p() { a__pass "${@}"; }
+a__pass() {
+ pass \
+ "${@}"
+}
+
+# copy passphrase into clipboard
+pc() { a__pass_clip "${@}"; }
+a__pass_clip() {
+ pass \
+ --clip \
+ "${@}"
+}
diff --git a/sh/alias/pgrep.sh b/sh/alias/pgrep.sh
new file mode 100644
index 0000000..72de025
--- /dev/null
+++ b/sh/alias/pgrep.sh
@@ -0,0 +1,7 @@
+# look for a string in processes names
+pg() { a__proc_grep "${@}"; }
+a__proc_grep() {
+ pgrep \
+ --list-full \
+ "${@}"
+}
diff --git a/sh/alias/pwgen.sh b/sh/alias/pwgen.sh
new file mode 100644
index 0000000..3b3dc83
--- /dev/null
+++ b/sh/alias/pwgen.sh
@@ -0,0 +1,17 @@
+# generate passwords
+pwg() { a__pass_word_gen "${@}"; }
+a__pass_word_gen() {
+ pwgen \
+ -1 \
+ --num-passwords 1048576 \
+ --secure \
+ "${@}"
+}
+
+# generate passwords with symbols
+pwgs() { a__pass_word_gen_symbols "${@}"; }
+a__pass_word_gen_symbols() {
+ a__pass_word_gen \
+ --symbols \
+ "${@}"
+}
diff --git a/sh/alias/rm.sh b/sh/alias/rm.sh
new file mode 100644
index 0000000..9634c1b
--- /dev/null
+++ b/sh/alias/rm.sh
@@ -0,0 +1,7 @@
+# remove interactively
+rmi() { a__re_move_interactive "${@}"; }
+a__re_move_interactive() {
+ rm \
+ --interactive \
+ "${@}"
+}
diff --git a/sh/alias/rsync.sh b/sh/alias/rsync.sh
new file mode 100644
index 0000000..bdbe4ed
--- /dev/null
+++ b/sh/alias/rsync.sh
@@ -0,0 +1,27 @@
+# synchronize
+rs() { a__r_sync "${@}"; }
+a__r_sync() {
+ rsync \
+ --archive \
+ --no-inc-recursive \
+ --partial \
+ --progress \
+ --verbose \
+ "${@}"
+}
+
+# synchronize and delete after
+rsda() { a__r_sync_delete_after "${@}"; }
+a__r_sync_delete_after() {
+ a__r_sync \
+ --delete-after \
+ "${@}"
+}
+
+# synchronize and delete before
+rsdb() { a__r_sync_delete_before "${@}"; }
+a__r_sync_delete_before() {
+ a__r_sync \
+ --delete-before \
+ "${@}"
+}
diff --git a/sh/alias/shell.sh b/sh/alias/shell.sh
new file mode 100644
index 0000000..d6f5801
--- /dev/null
+++ b/sh/alias/shell.sh
@@ -0,0 +1,29 @@
+# shorten alias
+a() {
+ alias \
+ "${@}"
+}
+
+# swap directory (current ↔ previous)
+sd() {
+ cd \
+ - ||
+ return
+}
+
+# exit terminal
+x() {
+ exit \
+ "${@}"
+}
+
+[ "${RWX_SHELL}" = "bash" ] || return
+
+# shellcheck disable=SC3033
+..() {
+ cd ..
+}
+# shellcheck disable=SC3033
+...() {
+ cd ../..
+}
diff --git a/sh/alias/tar.sh b/sh/alias/tar.sh
new file mode 100644
index 0000000..92b8fbf
--- /dev/null
+++ b/sh/alias/tar.sh
@@ -0,0 +1,31 @@
+tc() { a__tar_create "${@}"; }
+a__tar_create() {
+ a__tar_verbose \
+ --create \
+ --auto-compress \
+ --file \
+ "${@}"
+}
+
+tl() { a__tar_list "${@}"; }
+a__tar_list() {
+ a__tar_verbose \
+ --list \
+ --file \
+ "${@}"
+}
+
+tv() { a__tar_verbose "${@}"; }
+a__tar_verbose() {
+ tar \
+ --verbose \
+ "${@}"
+}
+
+tx() { a__tar_xtract "${@}"; }
+a__tar_xtract() {
+ a__tar_verbose \
+ --extract \
+ --file \
+ "${@}"
+}
diff --git a/sh/alias/tmux.sh b/sh/alias/tmux.sh
new file mode 100644
index 0000000..b30dc79
--- /dev/null
+++ b/sh/alias/tmux.sh
@@ -0,0 +1,5 @@
+tm() { a__t_mux "${@}"; }
+a__t_mux() {
+ tmux \
+ "${@}"
+}
diff --git a/sh/alias/tree.sh b/sh/alias/tree.sh
new file mode 100644
index 0000000..03be3de
--- /dev/null
+++ b/sh/alias/tree.sh
@@ -0,0 +1,12 @@
+t() { a__tree "${@}"; }
+a__tree() {
+ tree \
+ "${@}"
+}
+
+ta() { a__tree_all "${@}"; }
+a__tree_all() {
+ tree \
+ -a \
+ "${@}"
+}
diff --git a/sh/cryptsetup.sh b/sh/cryptsetup.sh
new file mode 100644
index 0000000..900083d
--- /dev/null
+++ b/sh/cryptsetup.sh
@@ -0,0 +1,6 @@
+_rwx_cmd_cs() { rwx_crypt_setup "${@}"; }
+
+rwx_crypt_setup() {
+ local action="${1}"
+ echo "cs: ${action}"
+}
diff --git a/sh/debian.sh b/sh/debian.sh
new file mode 100644
index 0000000..88e8b01
--- /dev/null
+++ b/sh/debian.sh
@@ -0,0 +1,76 @@
+RWX_DEBIAN_CODENAME="$(
+ grep "VERSION_CODENAME" "/etc/os-release" |
+ cut --delimiter "=" --fields "2"
+)"
+
+rwx_apt_clean() {
+ apt-get \
+ clean
+}
+
+rwx_apt_conf_write() {
+ printf "\
+Acquire::AllowInsecureRepositories False;
+Acquire::AllowWeakRepositories False;
+Acquire::AllowDowngradeToInsecureRepositories False;
+Acquire::Check-Valid-Until True;
+APT::Install-Recommends False;
+APT::Install-Suggests False;
+APT::Get::Show-Versions True;
+Dir::Etc::SourceParts \"\";
+Dpkg::Progress True;
+" >"/etc/apt/apt.conf.d/apt.conf"
+}
+
+rwx_apt_install_backports() {
+ rwx_apt_install_target "${RWX_DEBIAN_CODENAME}-backports" "${@}"
+}
+
+rwx_apt_install_release() {
+ rwx_apt_install_target "${RWX_DEBIAN_CODENAME}" "${@}"
+}
+
+rwx_apt_install_target() {
+ local target="${1}"
+ shift
+ local package
+ for package in "${@}"; do
+ rwx_log "" \
+ "${package} ← ${target}"
+ apt-get \
+ install \
+ --assume-yes \
+ --target-release "${target}" \
+ "${package}"
+ rwx_apt_clean
+ done
+}
+
+rwx_apt_sources_write() {
+ printf "%s" "\
+deb https://deb.debian.org/debian \
+${RWX_DEBIAN_CODENAME} main non-free-firmware contrib non-free
+deb https://deb.debian.org/debian \
+${RWX_DEBIAN_CODENAME}-backports main non-free-firmware contrib non-free
+deb https://deb.debian.org/debian \
+${RWX_DEBIAN_CODENAME}-updates main non-free-firmware contrib non-free
+deb https://deb.debian.org/debian-security \
+${RWX_DEBIAN_CODENAME}-security main non-free-firmware contrib non-free
+" >"/etc/apt/sources.list"
+}
+
+rwx_apt_update() {
+ apt-get \
+ update
+}
+
+rwx_apt_upgrade() {
+ apt-get \
+ upgrade \
+ --assume-yes
+ rwx_apt_clean
+}
+
+rwx_debian_frontend_disable() {
+ export DEBIAN_FRONTEND="noninteractive"
+}
diff --git a/sh/ffmpeg.sh b/sh/ffmpeg.sh
new file mode 100644
index 0000000..bcb3a62
--- /dev/null
+++ b/sh/ffmpeg.sh
@@ -0,0 +1,194 @@
+# ╭────────┬─────────┬───────╮
+# │ ffmpeg │ devices │ reset │
+# ╰────────┴─────────┴───────╯
+
+_rwx_cmd_rwx_ffmpeg_devices_reset() { rwx_ffmpeg_devices_reset "${@}"; }
+rwx_ffmpeg_devices_reset() {
+ local module="uvcvideo"
+ modprobe --remove "${module}" &&
+ modprobe "${module}"
+}
+
+# ╭────────┬────────┬─────────╮
+# │ ffmpeg │ device │ formats │
+# ╰────────┴────────┴─────────╯
+
+rwx_ffmpeg_device_formats() {
+ local device="${1}"
+ [ -n "${device}" ] || device="/dev/video0"
+ ffmpeg \
+ -f "v4l2" \
+ -list_formats "all" \
+ -i "${device}"
+}
+
+# ╭────────┬───────╮
+# │ ffmpeg │ input │
+# ╰────────┴───────╯
+
+rwx_ffmpeg_input_blue_yeti() {
+ local device="alsa_input.\
+usb-Generic_Blue_Microphones_2051BAB04XY8-00.analog-stereo"
+ set -- \
+ -f "pulse" \
+ -i "${device}" \
+ -ac "2" \
+ -ar "48000"
+ local argument
+ for argument in "${@}"; do echo "${argument}"; done
+}
+
+rwx_ffmpeg_input_dell_precision() {
+ local device="alsa_input.\
+pci-0000_00_1f.3.analog-stereo"
+ set -- \
+ -f "pulse" \
+ -i "${device}" \
+ -ac "2" \
+ -ar "48000"
+ local argument
+ for argument in "${@}"; do echo "${argument}"; done
+}
+
+rwx_ffmpeg_input_file() {
+ local file="${1}"
+ local from="${2}"
+ local to="${3}"
+ [ -n "${file}" ] || return
+ set -- \
+ -i "${file}"
+ if [ -n "${to}" ]; then
+ set -- "${@}" \
+ -ss "${from}" \
+ -to "${to}"
+ fi
+ local argument
+ for argument in "${@}"; do echo "${argument}"; done
+}
+
+rwx_ffmpeg_input_hdmi() {
+ local device="${1}"
+ [ -n "${device}" ] || device="/dev/video0"
+ set -- \
+ -f "v4l2" \
+ -video_size "1920x1080" \
+ -framerate "60" \
+ -input_format "yuyv422" \
+ -i "${device}"
+ local argument
+ for argument in "${@}"; do echo "${argument}"; done
+}
+
+# ╭────────┬────────╮
+# │ ffmpeg │ output │
+# ╰────────┴────────╯
+
+rwx_ffmpeg_output_audio_fast() {
+ set -- \
+ -codec:a "flac" \
+ -compression_level "0"
+ local argument
+ for argument in "${@}"; do echo "${argument}"; done
+}
+
+rwx_ffmpeg_output_audio_slow() {
+ set -- \
+ -codec:a "libopus" \
+ -b:a "128k"
+ local argument
+ for argument in "${@}"; do echo "${argument}"; done
+}
+
+rwx_ffmpeg_output_file() {
+ local file="${1}"
+ [ -n "${file}" ] || return
+ set -- \
+ -y "${file}"
+ local argument
+ for argument in "${@}"; do echo "${argument}"; done
+}
+
+rwx_ffmpeg_output_video_fast() {
+ set -- \
+ -codec:v "libx264" \
+ -preset "ultrafast" \
+ -crf "0"
+ local argument
+ for argument in "${@}"; do echo "${argument}"; done
+}
+
+rwx_ffmpeg_output_video_slow() {
+ local crf="${1}"
+ local codec="${2}"
+ [ -n "${codec}" ] || codec="libx264"
+ if [ -z "${crm}" ]; then
+ case "${codec}" in
+ "libx264") crf="23" ;;
+ "libx265") crf="28" ;;
+ *) ;;
+ esac
+ fi
+ set -- \
+ -codec:v "${codec}" \
+ -preset "veryslow" \
+ -crf "${crf}" \
+ -movflags "+faststart" \
+ -pix_fmt "yuv420p"
+ local argument
+ for argument in "${@}"; do echo "${argument}"; done
+}
+
+# ╭────────┬────────╮
+# │ ffmpeg │ record │
+# ╰────────┴────────╯
+
+rwx_ffmpeg_record_hdmi_precision() {
+ local file="${1}"
+ [ -n "${file}" ] || return
+ # LATER alternative
+ # shellcheck disable=SC2046,SC2312
+ set -- \
+ $(rwx_ffmpeg_input_hdmi) \
+ $(rwx_ffmpeg_input_dell_precision) \
+ $(rwx_ffmpeg_output_video_fast) \
+ $(rwx_ffmpeg_output_audio_fast) \
+ $(rwx_ffmpeg_output_file "${file}")
+ echo "${@}"
+ ffmpeg "${@}"
+}
+
+rwx_ffmpeg_record_hdmi_yeti() {
+ local file="${1}"
+ [ -n "${file}" ] || return
+ # LATER alternative
+ # shellcheck disable=SC2046,SC2312
+ set -- \
+ $(rwx_ffmpeg_input_hdmi) \
+ $(rwx_ffmpeg_input_blue_yeti) \
+ $(rwx_ffmpeg_output_video_fast) \
+ $(rwx_ffmpeg_output_audio_fast) \
+ $(rwx_ffmpeg_output_file "${file}")
+ echo "${@}"
+ ffmpeg "${@}"
+}
+
+# ╭────────┬────────╮
+# │ ffmpeg │ reduce │
+# ╰────────┴────────╯
+
+rwx_ffmpeg_reduce() {
+ local input="${1}"
+ local output="${2}"
+ local from="${3}"
+ local to="${4}"
+ [ -n "${output}" ] || return
+ # LATER alternative
+ # shellcheck disable=SC2046,SC2312
+ set -- \
+ $(rwx_ffmpeg_input_file "${input}" "${from}" "${to}") \
+ $(rwx_ffmpeg_output_video_slow) \
+ $(rwx_ffmpeg_output_audio_slow) \
+ $(rwx_ffmpeg_output_file "${output}")
+ echo "${@}"
+ ffmpeg "${@}"
+}
diff --git a/sh/fs.sh b/sh/fs.sh
new file mode 100644
index 0000000..ac46f6d
--- /dev/null
+++ b/sh/fs.sh
@@ -0,0 +1,121 @@
+rwx_fs_make_btrfs() {
+ local device="${1}"
+ local label="${2}"
+ local uuid="${3}"
+ if [ -b "${device}" ]; then
+ set -- \
+ --force \
+ --checksum "sha256"
+ if [ -n "${label}" ]; then
+ set -- "${@}" \
+ --label "${label}"
+ fi
+ if [ -n "${uuid}" ]; then
+ set -- "${@}" \
+ --uuid "${uuid}"
+ fi
+ mkfs.btrfs "${@}" "${device}"
+ fi
+}
+
+rwx_fs_make_btrfs_swap() {
+ local path="${1}"
+ local size="${2}"
+ local uuid="${3}"
+ if [ -n "${path}" ]; then
+ set -- filesystem mkswapfile
+ if [ -n "${size}" ]; then
+ set -- "${@}" \
+ --size "${size}"
+ fi
+ if [ -n "${uuid}" ]; then
+ set -- "${@}" \
+ --uuid "${uuid}"
+ fi
+ btrfs "${@}" "${path}"
+ fi
+}
+
+rwx_fs_make_fat() {
+ local device="${1}"
+ local name="${2}"
+ local volid="${3}"
+ if [ -b "${device}" ]; then
+ set -- \
+ -F 32 \
+ -S 4096
+ if [ -n "${name}" ]; then
+ set -- "${@}" \
+ -n "${name}"
+ fi
+ if [ -n "${volid}" ]; then
+ set -- "${@}" \
+ -i "${volid}"
+ fi
+ mkfs.fat "${@}" "${device}"
+ fi
+}
+
+rwx_fs_raid_create() {
+ if [ -n "${4}" ]; then
+ local name="${1}"
+ local uuid="${2}"
+ shift 2
+ mdadm \
+ --create "/dev/md/${name}" \
+ --level 0 \
+ --metadata 1 \
+ --name "md:${name}" \
+ --raid-devices ${#} \
+ --uuid "${uuid}" \
+ "${@}"
+ fi
+}
+
+rwx_fs_wipe() {
+ local device="${1}"
+ local buffer="${2}"
+ local count="${3}"
+ if [ -b "${device}" ]; then
+ set -- \
+ status="progress" \
+ if="/dev/zero" \
+ of="${device}"
+ if [ -n "${buffer}" ]; then
+ set -- "${@}" \
+ bs="${buffer}"
+ fi
+ if [ -n "${count}" ]; then
+ set -- "${@}" \
+ count="${count}"
+ fi
+ dd "${@}"
+ fi
+}
+
+rwx_fs_luks_format() {
+ local passphrase="${1}"
+ local device="${2}"
+ local label="${3}"
+ local uuid="${4}"
+ if [ -b "${device}" ]; then
+ set -- \
+ --batch-mode \
+ --cipher "aes-xts-plain64" \
+ --hash "sha512" \
+ --iter-time 4096 \
+ --key-size 512 \
+ --pbkdf "argon2id" \
+ --type "luks2" \
+ --use-random \
+ --verbose
+ if [ -n "${label}" ]; then
+ set -- "${@}" --label "${label}"
+ fi
+ if [ -n "${uuid}" ]; then
+ set -- "${@}" --uuid "${uuid}"
+ fi
+ echo "${passphrase}" |
+ cryptsetup "${@}" luksFormat "${device}"
+ fi
+}
diff --git a/sh/gnome.sh b/sh/gnome.sh
new file mode 100644
index 0000000..b17a058
--- /dev/null
+++ b/sh/gnome.sh
@@ -0,0 +1,71 @@
+# ╭───────┬────────────╮
+# │ gnome │ background │
+# ╰───────┴────────────╯
+
+rwx_gnome_background_black() {
+ rwx_gnome_set_background "color-shading-type" "solid"
+ rwx_gnome_set_background "primary-color" "#000000"
+}
+
+rwx_gnome_background_white() {
+ rwx_gnome_set_background "color-shading-type" "solid"
+ rwx_gnome_set_background "primary-color" "#ffffff"
+}
+
+rwx_gnome_background_win3() {
+ rwx_gnome_set_background "color-shading-type" "vertical"
+ rwx_gnome_set_background "primary-color" "#000000"
+ rwx_gnome_set_background "secondary-color" "#0000ff"
+}
+
+# ╭───────┬───────╮
+# │ gnome │ proxy │
+# ╰───────┴───────╯
+
+rwx_gnome_proxy() {
+ local value
+ case "${1}" in
+ "on") value="manual" ;;
+ *) value="none" ;;
+ esac
+ gsettings set "org.gnome.system.proxy" "mode" "${value}"
+}
+
+# ╭───────┬─────╮
+# │ gnome │ set │
+# ╰───────┴─────╯
+
+rwx_gnome_set() {
+ local group="${1}"
+ local key="${2}"
+ local value="${3}"
+ [ -n "${value}" ] || return
+ gsettings set "${group}" "${key}" "${value}"
+}
+
+rwx_gnome_set_background() {
+ local key="${1}"
+ local value="${2}"
+ [ -n "${value}" ] || return
+ rwx_gnome_set "org.gnome.desktop.background" "${key}" "${value}"
+}
+
+# ╭───────┬────────────╮
+# │ gnome │ workspaces │
+# ╰───────┴────────────╯
+
+rwx_gnome_workspaces_primary() {
+ local bool
+ local group="org.gnome.mutter"
+ local name="workspaces-only-on-primary"
+ local var="${group}/${name}"
+ # get
+ bool="$(gsettings get "${group}" "${name}")"
+ rwx_log_debug "${var}: ${bool}"
+ # not
+ bool="$(rwx_not "${bool}")"
+ rwx_log_debug "bool: ${bool}"
+ # set
+ gsettings set "${group}" "${name}" "${bool}"
+ rwx_log_info "${var}: ${bool}"
+}
diff --git a/sh/gpg.sh b/sh/gpg.sh
new file mode 100644
index 0000000..b5ce07b
--- /dev/null
+++ b/sh/gpg.sh
@@ -0,0 +1,19 @@
+# bind gpg agent to current tty
+rwx_gpg_agent_update() {
+ gpg-connect-agent \
+ updatestartuptty \
+ /bye
+}
+
+rwx_gpg_ssh_auth_sock() {
+ local user_id
+ user_id=$(id --user)
+ if [ "${user_id}" -ne 0 ]; then
+ if [ -f "${HOME}/.gnupg/gpg-agent.conf" ]; then
+ SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
+ export SSH_AUTH_SOCK
+ fi
+ fi
+}
+
+rwx_gpg_ssh_auth_sock
diff --git a/sh/lint/gitlint.sh b/sh/lint/gitlint.sh
new file mode 100644
index 0000000..643333c
--- /dev/null
+++ b/sh/lint/gitlint.sh
@@ -0,0 +1,6 @@
+rwx_gitlint() {
+ local path="${1}"
+ gitlint \
+ --target "${path}" \
+ "lint"
+}
diff --git a/sh/lint/lint.sh b/sh/lint/lint.sh
new file mode 100644
index 0000000..365773f
--- /dev/null
+++ b/sh/lint/lint.sh
@@ -0,0 +1,78 @@
+# lint code
+rwx_lint() {
+ local path="${1}"
+ [ -n "${path}" ] || return 1
+ rwx_lint_clean "${path}"
+ rwx_lint_tasks "${path}"
+ set \
+ "python" \
+ "shell"
+ local code
+ for code in "${@}"; do
+ rwx_log "" "${code}"
+ "rwx_lint_${code}" "${path}"
+ done
+ rwx_lint_clean "${path}"
+}
+
+# clean
+rwx_lint_clean() {
+ local path="${1}"
+ [ -n "${path}" ] || return 1
+ rwx_log "" "clean" ""
+ py3clean "${path}"
+ set \
+ "mypy" \
+ "ruff"
+ local tool
+ for tool in "${@}"; do
+ rwx_remove "${path}/.${tool}_cache"
+ done
+}
+
+# lint python code
+rwx_lint_python() {
+ local path="${1}"
+ local action
+ set \
+ "pylint" \
+ "pydoclint" \
+ "mypy" \
+ "ruff"
+ for action in "${@}"; do
+ rwx_log "" "${action}"
+ "rwx_${action}" "${path}"
+ done
+}
+
+# lint shell code
+rwx_lint_shell() {
+ local path="${1}"
+ local action
+ set \
+ "shellcheck" \
+ "shfmt"
+ for action in "${@}"; do
+ rwx_log "" "${action}"
+ "rwx_${action}" "${path}"
+ done
+}
+
+# lint code tasks
+rwx_lint_tasks() {
+ local path="${1}"
+ local type
+ set \
+ "LATER" \
+ "TODO" \
+ "FIXME"
+ for type in "${@}"; do
+ rwx_log "" "${type}"
+ grep \
+ --after "1" \
+ --directories "recurse" \
+ --line-number \
+ " ${type}" \
+ "${path}"
+ done
+}
diff --git a/sh/lint/mypy.sh b/sh/lint/mypy.sh
new file mode 100644
index 0000000..586d409
--- /dev/null
+++ b/sh/lint/mypy.sh
@@ -0,0 +1,4 @@
+rwx_mypy() {
+ local path="${1}"
+ mypy "${path}"
+}
diff --git a/sh/lint/pydoclint.sh b/sh/lint/pydoclint.sh
new file mode 100644
index 0000000..d2eb72f
--- /dev/null
+++ b/sh/lint/pydoclint.sh
@@ -0,0 +1,9 @@
+rwx_pydoclint() {
+ local path="${1}"
+ pydoclint \
+ --allow-init-docstring True \
+ --quiet \
+ --skip-checking-short-docstrings False \
+ --style "sphinx" \
+ "${path}"
+}
diff --git a/sh/lint/pylint.sh b/sh/lint/pylint.sh
new file mode 100644
index 0000000..52e1bd1
--- /dev/null
+++ b/sh/lint/pylint.sh
@@ -0,0 +1,6 @@
+rwx_pylint() {
+ local path="${1}"
+ pylint \
+ --enable-all-extensions \
+ "${path}/**/*.py"
+}
diff --git a/sh/lint/ruff.sh b/sh/lint/ruff.sh
new file mode 100644
index 0000000..e15b701
--- /dev/null
+++ b/sh/lint/ruff.sh
@@ -0,0 +1,28 @@
+rwx_ruff() {
+ local path="${1}"
+ local action
+ set \
+ "check" \
+ "format"
+ for action in "${@}"; do
+ "rwx_ruff_${action}" "${path}"
+ done
+}
+
+rwx_ruff_check() {
+ local path="${1}"
+ ruff check \
+ --ignore "D203,D213" \
+ --isolated \
+ --select "ALL" \
+ "${path}"
+}
+
+rwx_ruff_format() {
+ local path="${1}"
+ ruff format \
+ --diff \
+ --isolated \
+ --line-length "80" \
+ "${path}"
+}
diff --git a/sh/lint/shellcheck.sh b/sh/lint/shellcheck.sh
new file mode 100644
index 0000000..ac8c700
--- /dev/null
+++ b/sh/lint/shellcheck.sh
@@ -0,0 +1,34 @@
+rwx_shellcheck() {
+ local root="${1}"
+ local file module modules path
+ file="$(mktemp)"
+ modules="$(rwx_find_shell "${root}")"
+ rwx_ifs_set
+ for module in ${modules}; do
+ path="${root}/${module}"
+ echo ". \"${path}\"" >>"${file}"
+ done
+ rwx_ifs_unset
+ rwx_shellcheck_file "${file}"
+ rwx_remove "${file}"
+}
+
+rwx_shellcheck_file() {
+ local file="${1}"
+ shellcheck \
+ --check-sourced \
+ --enable "all" \
+ --exclude "3043" \
+ --external-sources \
+ --shell "dash" \
+ "${file}"
+}
+
+rwx_shellcheck_write() {
+ rwx_file_write ".shellcheckrc" "\
+disable=3043
+enable=all
+external-sources=true
+shell=sh
+"
+}
diff --git a/sh/lint/shfmt.sh b/sh/lint/shfmt.sh
new file mode 100644
index 0000000..4d6e894
--- /dev/null
+++ b/sh/lint/shfmt.sh
@@ -0,0 +1,4 @@
+rwx_shfmt() {
+ local path="${1}"
+ shfmt --diff "${path}"
+}
diff --git a/sh/live.sh b/sh/live.sh
new file mode 100644
index 0000000..7d82bfc
--- /dev/null
+++ b/sh/live.sh
@@ -0,0 +1,6 @@
+# remount read-only medium in read-write
+rwx_live_medium_remount() {
+ mount \
+ -o "remount,rw" \
+ "/usr/lib/live/mount/medium"
+}
diff --git a/sh/log/log.sh b/sh/log/log.sh
new file mode 100644
index 0000000..b921d45
--- /dev/null
+++ b/sh/log/log.sh
@@ -0,0 +1,68 @@
+RWX_LOG_LEVEL_FATAL=0
+RWX_LOG_LEVEL_ERROR=1
+RWX_LOG_LEVEL_WARN=2
+RWX_LOG_LEVEL_INFO=3
+RWX_LOG_LEVEL_DEBUG=4
+RWX_LOG_LEVEL_TRACE=5
+
+RWX_LOG_LEVEL=${RWX_LOG_LEVEL_INFO}
+
+rwx_log() { rwx_log_info "${@}"; }
+
+rwx_log_debug() {
+ if [ "${RWX_LOG_LEVEL}" -ge "${RWX_LOG_LEVEL_DEBUG}" ]; then
+ _rwx_log "[DEBUG]" "${@}"
+ fi
+}
+
+rwx_log_error() {
+ local code="${1}"
+ shift
+ [ -n "${code}" ] || rwx_log_fatal 1 "No error code"
+ if [ "${RWX_LOG_LEVEL}" -ge "${RWX_LOG_LEVEL_ERROR}" ]; then
+ _rwx_log "[ERROR]" "${@}" >&2
+ return "${code}"
+ fi
+}
+
+rwx_log_fatal() {
+ local code="${1}"
+ shift
+ [ -n "${code}" ] || rwx_log_fatal 1 "No error code"
+ if [ "${RWX_LOG_LEVEL}" -ge "${RWX_LOG_LEVEL_FATAL}" ]; then
+ _rwx_log "[FATAL]" "${@}" >&2
+ exit "${code}"
+ fi
+}
+
+rwx_log_info() {
+ if [ "${RWX_LOG_LEVEL}" -ge "${RWX_LOG_LEVEL_INFO}" ]; then
+ _rwx_log "" "${@}"
+ fi
+}
+
+rwx_log_trace() {
+ if [ "${RWX_LOG_LEVEL}" -ge "${RWX_LOG_LEVEL_TRACE}" ]; then
+ _rwx_log "[TRACE]" "${@}"
+ fi
+}
+
+rwx_log_warn() {
+ if [ "${RWX_LOG_LEVEL}" -ge "${RWX_LOG_LEVEL_WARN}" ]; then
+ _rwx_log "[ WARN]" "${@}"
+ fi
+}
+
+_rwx_log() {
+ local prefix="${1}"
+ shift
+ [ ${#} -gt 0 ] || set -- ""
+ local line
+ for line in "${@}"; do
+ if [ -n "${prefix}" ]; then
+ __rwx_log "${prefix} ${line}"
+ else
+ __rwx_log "${line}"
+ fi
+ done
+}
diff --git a/sh/log/step.sh b/sh/log/step.sh
new file mode 100644
index 0000000..34a5c64
--- /dev/null
+++ b/sh/log/step.sh
@@ -0,0 +1,25 @@
+# ╭───────────────╮
+# │ __ = internal │
+# ╰───────────────╯
+
+# __RWX_BAR_TOP
+# __RWX_BAR_MIDDLE
+# __RWX_BAR_BOTTOM
+
+# __RWX_STEP_LEVEL
+# __RWX_STEP_level_INDEX
+# __RWX_STEP_level_LABEL
+
+# ╭─────────────╮
+# │ _ = private │
+# ╰─────────────╯
+
+_RWX_BOX_DOWN_AND_HORIZONTAL="┬"
+_RWX_BOX_DOWN_AND_LEFT="╮"
+_RWX_BOX_DOWN_AND_RIGHT="╭"
+_RWX_BOX_HORIZONTAL="─"
+_RWX_BOX_LEFT="╴"
+_RWX_BOX_UP_AND_HORIZONTAL="┴"
+_RWX_BOX_UP_AND_LEFT="╯"
+_RWX_BOX_UP_AND_RIGHT="╰"
+_RWX_BOX_VERTICAL="│"
diff --git a/sh/main.sh b/sh/main.sh
new file mode 100755
index 0000000..802418f
--- /dev/null
+++ b/sh/main.sh
@@ -0,0 +1,144 @@
+#! /usr/bin/env sh
+
+# ╭──────┬───────────╮
+# │ main │ constants │
+# ╰──────┴───────────╯
+
+RWX_MAIN_NAME="main.sh"
+RWX_SELF_NAME="rwx"
+
+RWX_SELF_COMMAND="_${RWX_SELF_NAME}_cmd_"
+
+# ╭──────┬───────────╮
+# │ main │ variables │
+# ╰──────┴───────────╯
+
+RWX_COMMAND_ARGUMENT="${0}"
+RWX_SHELL="$(cat "/proc/${$}/comm")"
+
+RWX_COMMAND_NAME="$(basename "${RWX_COMMAND_ARGUMENT}" |
+ sed "s|^-||")"
+case "${RWX_COMMAND_NAME}" in
+"bash" | "dash" | "sh") unset RWX_COMMAND_NAME ;;
+*) ;;
+esac
+RWX_ROOT_SYSTEM="/usr/local/lib/${RWX_SELF_NAME}"
+RWX_SELF_USER="${HOME}/${RWX_SELF_NAME}"
+
+RWX_MAIN_PATH="${RWX_ROOT_SYSTEM}/${RWX_MAIN_NAME}"
+
+# ╭──────┬───────╮
+# │ main │ shell │
+# ╰──────┴───────╯
+
+# test if active shell is in interactive mode
+rwx_shell_interactive() {
+ case "${-}" in
+ *i*) ;;
+ *) return 1 ;;
+ esac
+}
+
+# ╭──────┬─────╮
+# │ main │ log │
+# ╰──────┴─────╯
+
+__rwx_log() {
+ if rwx_shell_interactive; then
+ [ ${#} -gt 0 ] || set -- ""
+ local line
+ for line in "${@}"; do
+ echo "${line}"
+ done
+ fi
+}
+
+# ╭──────┬──────╮
+# │ main │ find │
+# ╰──────┴──────╯
+
+# find directory’s files by extension
+rwx_find_extension() {
+ local extension="${1}"
+ local root="${2}"
+ local file="${3}"
+ set -- \
+ "${root}" \
+ -name "*.${extension}" \
+ -type "f"
+ [ -n "${file}" ] &&
+ set -- "${@}" \
+ -not \
+ -name "${file}"
+ find "${@}" \
+ -printf "%P\n" |
+ sort
+}
+
+# find directory’s sh files
+rwx_find_shell() {
+ rwx_find_extension "sh" "${@}"
+}
+
+# ╭──────┬─────╮
+# │ main │ ifs │
+# ╰──────┴─────╯
+
+rwx_ifs_set() {
+ _RWX_IFS="${IFS}"
+ IFS="
+"
+}
+
+rwx_ifs_unset() {
+ IFS="${_RWX_IFS}"
+ unset RWX_IFS
+}
+
+# ╭──────┬────────╮
+# │ main │ source │
+# ╰──────┴────────╯
+
+rwx_source() {
+ local path="${1}"
+ [ -d "${path}" ] ||
+ return 1
+ local count module
+ count=0
+ __rwx_log "" \
+ ". ${path}"
+ rwx_ifs_set
+ for module in $(rwx_find_shell "${path}" "${RWX_MAIN_NAME}"); do
+ count=$((count + 1))
+ __rwx_log "$(printf "%02d" "${count}") ${module%.sh}"
+ module="${path}/${module}"
+ # shellcheck disable=SC1090
+ . "${module}"
+ done
+ rwx_ifs_unset
+}
+
+# ╭──────╮
+# │ main │
+# ╰──────╯
+
+# run initial steps
+rwx_main() {
+ # system root
+ if ! rwx_source "${RWX_ROOT_SYSTEM}"; then
+ __rwx_log "Not a directory: ${RWX_ROOT_SYSTEM}"
+ return 1
+ fi
+ # user root
+ rwx_source "${RWX_SELF_USER}"
+ # context / command
+ if [ -n "${RWX_COMMAND_NAME}" ]; then
+ "${RWX_SELF_COMMAND}${RWX_COMMAND_NAME}" "${@}"
+ # context / shell
+ else
+ rwx_self_init
+ fi
+}
+
+# run main function
+rwx_main "${@}"
diff --git a/sh/python.sh b/sh/python.sh
new file mode 100644
index 0000000..44ea181
--- /dev/null
+++ b/sh/python.sh
@@ -0,0 +1,14 @@
+# ╭────────╮
+# │ python │
+# ╰────────╯
+
+# ╭────────┬──────╮
+# │ python │ venv │
+# ╰────────┴──────╯
+
+rwx_python_venv() {
+ local path="${1}"
+ [ -d "${path}" ] || return 1
+ export VIRTUAL_ENV="${path}" && \
+ export PATH="${VIRTUAL_ENV}/bin:${PATH}"
+}
diff --git a/sh/rescue/common.sh b/sh/rescue/common.sh
new file mode 100644
index 0000000..4e2684a
--- /dev/null
+++ b/sh/rescue/common.sh
@@ -0,0 +1,94 @@
+rwx_rescue_configure() {
+ local hostname="${1}"
+ # apt / conf
+ rwx_apt_conf_write
+ # apt / sources
+ rwx_apt_sources_write
+ # bash / rc
+ main_link_bashrc
+ mv "${HOME}/.bashrc" "${HOME}/.bashrc.old"
+ # host name
+ hostname "${hostname}"
+ # locales
+ printf "\
+en_US.UTF-8 UTF-8
+fr_FR.UTF-8 UTF-8
+" >"/etc/locale.gen"
+ # generate locales
+ locale-gen
+ # update catalog
+ rwx_apt_update
+ # disable frontend
+ rwx_debian_frontend_disable
+ # install backports
+ rwx_apt_install_backports "tmux"
+ # install packages
+ rwx_apt_install_release "apt-file" "mosh" "screen" "byobu"
+ # update catalog
+ rwx_apt_update
+}
+
+rwx_rescue_install() {
+ # update catalog
+ rwx_apt_update
+ # disable frontend
+ rwx_debian_frontend_disable
+ # upgrade packages
+ rwx_apt_upgrade
+ # install packages
+ rwx_apt_install_release \
+ "man-db" \
+ "dmidecode" "efibootmgr" "lshw" "pciutils" "usbutils" \
+ "parted" "mdadm" "cryptsetup-bin" "lvm2" \
+ "btrfs-progs" "dosfstools" "duperemove" "squashfs-tools" \
+ "git" "micro" "nano" "python3" "rsync" "vim" \
+ "exa" "lf" "ncdu" "nnn" "ranger" "tree" \
+ "file" "htop" "iotop" "ipcalc" "libdigest-sha3-perl" "lsof"
+ # install backports
+ rwx_apt_install_backports \
+ "grub-pc-bin" \
+ \
+ "grub-efi-amd64-bin"
+}
+
+rwx_rescue_upload() {
+ local host="${1}"
+ local hostname="${2}"
+ if [ -n "${hostname}" ]; then
+ local user="root"
+ #
+ local user_host="${user}@${host}"
+ # remove fingerprints
+ ssh-keygen -R "${host}"
+ # copy ssh id
+ ssh-copy-id \
+ -o "StrictHostKeyChecking=accept-new" \
+ "${user_host}"
+ # upload root
+ rsync --delete --recursive \
+ "$(dirname "${ENV}")" "${user_host}:/etc"
+ # call setup
+ # TODO variable
+ ssh "${user_host}" -- \
+ ". \"${ENV}\" ; rwx_rescue_configure \"${hostname}\""
+ # create session
+ ssh "${user_host}" -- byobu new-session -d
+ # send keys
+ ssh "${user_host}" -- byobu send-keys "rwx_rescue_install" "C-m"
+ # attach session
+ mosh "${user_host}" -- byobu attach-session
+ else
+ echo "host & hostname"
+ return 1
+ fi
+}
+
+rwx_rescue_wipe_1_zero() {
+ rwx_fs_wipe "/dev/mapper/crypt" "512M"
+}
+
+rwx_rescue_wipe_3_close() {
+ umount "/media/boot"
+ umount "/media/crypt" &&
+ cryptsetup luksClose "crypt"
+}
diff --git a/sh/rescue/hetzner.sh b/sh/rescue/hetzner.sh
new file mode 100644
index 0000000..24d6bd0
--- /dev/null
+++ b/sh/rescue/hetzner.sh
@@ -0,0 +1,129 @@
+rwx_rescue_wipe_0_init_hetzner_8_8() {
+ local device
+ set \
+ "/dev/sda" \
+ "/dev/sdb"
+ local members
+ local number
+ local passphrase
+ # read passphrase
+ passphrase="$(rwx_read_passphrase)"
+ # warn
+ rwx_warn_wipe "${@}"
+ #
+ number=0
+ for device in "${@}"; do
+ number=$((number + 1))
+ echo
+ echo "#${number}: ${device}"
+ #
+ parted --script "${device}" \
+ mktable gpt \
+ unit "mib" \
+ mkpart "crypt-${number}" 33282 7630885 \
+ mkpart "boot-${number}" 514 33282 \
+ mkpart "esp-${number}" 2 514 \
+ set 3 esp on \
+ mkpart "bios-${number}" 1 2 \
+ set 4 bios_grub on
+ done
+ #
+ number=0
+ for device in "${@}"; do
+ number=$((number + 1))
+ echo
+ echo "#${number}: ${device}4"
+ # wipe bios
+ rwx_fs_wipe "${device}4"
+ done
+ #
+ number=0
+ for device in "${@}"; do
+ number=$((number + 1))
+ echo
+ echo "#${number}: ${device}3"
+ # format esp
+ rwx_fs_wipe "${device}3" "1M"
+ rwx_fs_make_fat "${device}3" "esp-${number}" "0000000${number}"
+ # mount esp
+ mkdir --parents "/media/esp/${number}"
+ mount "${device}3" "/media/esp/${number}"
+ done
+ #
+ number=0
+ for device in "${@}"; do
+ number=$((number + 1))
+ echo
+ echo "#${number}: ${device}2"
+ # wipe boot
+ rwx_fs_wipe "${device}2" "1G" 1
+ done
+ #
+ members=""
+ for device in "${@}"; do
+ members="${members} ${device}2"
+ done
+ # LATER alternative
+ # shellcheck disable=SC2086
+ rwx_fs_raid_create \
+ "boot" "00000000:00000000:00000000:00000002" ${members}
+ #
+ rwx_fs_make_btrfs "/dev/md/boot" "boot" \
+ "00000000-0000-0000-0000-00000000000b"
+ # mount boot
+ mkdir --parents "/media/boot"
+ mount \
+ --options "autodefrag,compress-force=zstd" \
+ "/dev/md/boot" "/media/boot"
+ #
+ number=0
+ for device in "${@}"; do
+ number=$((number + 1))
+ echo
+ echo "#${number}: ${device}1"
+ # wipe crypt head
+ rwx_fs_wipe "${device}1" "1G" 1
+ done
+ #
+ members=""
+ for device in "${@}"; do
+ members="${members} ${device}1"
+ done
+ # LATER alternative
+ # shellcheck disable=SC2086
+ rwx_fs_raid_create \
+ "crypt" "00000000:00000000:00000000:00000001" ${members}
+ # encrypt
+ rwx_fs_luks_format "${passphrase}" "/dev/md/crypt"
+ # open
+ echo "${passphrase}" |
+ cryptsetup luksOpen "/dev/md/crypt" "crypt"
+ # passphrase
+ unset passphrase
+}
+
+rwx_rescue_wipe_2_make_hetzner_8_8() {
+ local passphrase
+ # close
+ cryptsetup luksClose "crypt"
+ # read passphrase
+ passphrase="$(rwx_read_passphrase)"
+ # encrypt
+ rwx_fs_luks_format "${passphrase}" "/dev/md/crypt"
+ # open
+ echo "${passphrase}" |
+ cryptsetup luksOpen "/dev/md/crypt" "crypt"
+ # passphrase
+ unset passphrase
+ # format crypt
+ rwx_fs_make_btrfs "/dev/mapper/crypt" "crypt" \
+ "00000000-0000-0000-0000-00000000000c"
+ # mount crypt
+ mkdir --parents "/media/crypt"
+ mount \
+ --options "autodefrag,compress-force=zstd" \
+ "/dev/mapper/crypt" "/media/crypt"
+ # make swap file
+ rwx_fs_make_btrfs_swap "/media/crypt/swap" "64g" \
+ "00000000-0000-0000-0000-000000000005"
+}
diff --git a/sh/rescue/ovh.sh b/sh/rescue/ovh.sh
new file mode 100644
index 0000000..b4120e2
--- /dev/null
+++ b/sh/rescue/ovh.sh
@@ -0,0 +1,71 @@
+rwx_rescue_wipe_0_init_ovh_vle2() {
+ local device="/dev/sdb"
+ local passphrase
+ # read passphrase
+ passphrase="$(rwx_read_passphrase)"
+ # warn
+ rwx_warn_wipe "${device}"
+ #
+ parted --script "${device}" \
+ mktable gpt \
+ unit "mib" \
+ mkpart "crypt" 4610 40959 \
+ mkpart "boot" 514 4610 \
+ mkpart "esp" 2 514 \
+ set 3 esp on \
+ mkpart bios 1 2 \
+ set 4 bios_grub on
+ # bios / wipe
+ rwx_fs_wipe "${device}4"
+ # esp / wipe
+ rwx_fs_wipe "${device}3" "1M"
+ # esp / format
+ rwx_fs_make_fat "${device}3" "esp" "00000001"
+ # esp / mount
+ mkdir --parents "/media/esp"
+ mount "${device}3" "/media/esp"
+ # boot / wipe
+ rwx_fs_wipe "${device}2" "1G" 1
+ # boot / format
+ rwx_fs_make_btrfs "${device}2" "boot" \
+ "00000000-0000-0000-0000-00000000000b"
+ # boot / mount
+ mkdir --parents "/media/boot"
+ mount --options "autodefrag,compress-force=zstd" \
+ "${device}2" "/media/boot"
+ # crypt / wipe
+ rwx_fs_wipe "${device}1" "1G" 1
+ # crypt / encrypt
+ rwx_fs_luks_format "${passphrase}" "${device}1"
+ # crypt / open
+ echo "${passphrase}" |
+ cryptsetup luksOpen "${device}1" "crypt"
+ # passphrase
+ unset passphrase
+}
+
+rwx_rescue_wipe_2_make_ovh_vle2() {
+ local device="/dev/sdb"
+ local passphrase
+ # crypt / close
+ cryptsetup luksClose "crypt"
+ # read passphrase
+ passphrase="$(rwx_read_passphrase)"
+ # crypt / encrypt
+ rwx_fs_luks_format "${passphrase}" "${device}1"
+ # crypt / open
+ echo "${passphrase}" |
+ cryptsetup luksOpen "${device}1" "crypt"
+ # passphrase
+ unset passphrase
+ # crypt / format
+ rwx_fs_make_btrfs "/dev/mapper/crypt" "crypt" \
+ "00000000-0000-0000-0000-00000000000c"
+ # crypt / mount
+ mkdir --parents "/media/crypt"
+ mount --options "autodefrag,compress-force=zstd" \
+ "/dev/mapper/crypt" "/media/crypt"
+ # crypt / swap
+ rwx_fs_make_btrfs_swap "/media/crypt/swap" "4g" \
+ "00000000-0000-0000-0000-000000000005"
+}
diff --git a/sh/self.sh b/sh/self.sh
new file mode 100644
index 0000000..732e9c2
--- /dev/null
+++ b/sh/self.sh
@@ -0,0 +1,162 @@
+# meta doc
+rwx_doc() {
+ local name="${1}"
+ [ -n "${name}" ] || return
+ local doc line module
+ rwx_ifs_set
+ for module in $(rwx_find_shell "${RWX_ROOT_SYSTEM}"); do
+ while read -r line; do
+ case "${line}" in
+ "#"*) doc="${doc}${line}" ;;
+ "${name}() {")
+ echo "${doc}"
+ return
+ ;;
+ *) doc="" ;;
+ esac
+ done <"${RWX_ROOT_SYSTEM}/${module}"
+ done
+ rwx_ifs_unset
+}
+
+# ╭──────┬───────╮
+# │ self │ check │
+# ╰──────┴───────╯
+
+# check source code
+rwx_self_check() {
+ # check format
+ rwx_log
+ rwx_shfmt "${RWX_ROOT_SYSTEM}"
+ # check syntax
+ rwx_log
+ rwx_shellcheck "${RWX_ROOT_SYSTEM}"
+}
+
+# ╭──────┬──────────╮
+# │ self │ commands │
+# ╰──────┴──────────╯
+
+# get commands from root
+rwx_self_commands() {
+ grep \
+ --directories "recurse" \
+ --no-filename \
+ "^${RWX_SELF_COMMAND}" "${RWX_ROOT_SYSTEM}" |
+ cut --delimiter "(" --fields 1 |
+ sed "s|^${RWX_SELF_COMMAND}||"
+}
+
+# ╭──────┬───────────╮
+# │ self │ functions │
+# ╰──────┴───────────╯
+
+# get functions from root
+rwx_self_functions() {
+ grep \
+ --directories "recurse" \
+ --no-filename \
+ "()" "${RWX_ROOT_SYSTEM}" |
+ cut --delimiter "(" --fields 1
+}
+
+# ╭──────┬──────╮
+# │ self │ help │
+# ╰──────┴──────╯
+
+# output help message
+rwx_self_help() {
+ rwx_log \
+ "rwx_… = functions" \
+ " a__… = aliases" \
+ " u__… = user"
+}
+
+# ╭──────┬──────╮
+# │ self │ init │
+# ╰──────┴──────╯
+
+rwx_self_init() {
+ # run interactive extras
+ if rwx_shell_interactive; then
+ # help
+ rwx_log
+ rwx_self_help
+ fi
+}
+
+# ╭──────┬─────────╮
+# │ self │ install │
+# ╰──────┴─────────╯
+
+_rwx_cmd_rwx_install() { rwx_self_install "${@}"; }
+rwx_self_install() {
+ local target="${1}"
+ local command file root
+ # code
+ if [ -n "${target}" ]; then
+ root="${target}${RWX_ROOT_SYSTEM}"
+ rwx_remove "${root}"
+ cp --recursive "${RWX_ROOT_SYSTEM}" "${root}"
+ fi
+ # commands
+ root="${target}/usr/local/bin"
+ for command in $(rwx_self_commands); do
+ file="${root}/${command}"
+ rwx_remove "${file}"
+ rwx_link "${file}" "${RWX_MAIN_PATH}"
+ done
+ # sh
+ file="${target}/etc/profile.d/${RWX_SELF_NAME}.sh"
+ rwx_remove "${file}"
+ rwx_file_write "${file}" "\
+export ENV=\"${RWX_MAIN_PATH}\"
+"
+ # bash
+ file="${target}/etc/bash.bashrc"
+ rwx_remove "${file}"
+ rwx_link "${file}" "${RWX_MAIN_PATH}"
+}
+
+# ╭──────┬────────╮
+# │ self │ subset │
+# ╰──────┴────────╯
+
+rwx_self_subset() {
+ local argument path
+ for argument in "${@}"; do
+ path="${RWX_ROOT_SYSTEM}/${argument}"
+ if [ -d "${path}" ]; then
+ local file
+ for file in $(rwx_find_shell "${path}"); do
+ echo "${argument}/${file}"
+ done
+ elif [ -f "${path}" ]; then
+ echo "${argument}"
+ fi
+ done
+}
+
+# ╭──────┬───────╮
+# │ self │ write │
+# ╰──────┴───────╯
+
+rwx_self_write() {
+ local target="${1}"
+ if [ -n "${target}" ]; then
+ shift
+ local file text
+ text="#! /usr/bin/env sh
+"
+ rwx_ifs_set
+ for file in $(rwx_self_subset "${@}"); do
+ text="${text}
+$(cat "${RWX_ROOT_SYSTEM}/${file}")
+"
+ done
+ rwx_ifs_unset
+ rwx_file_write "${target}" "${text}"
+ rwx_shfmt "${target}"
+ rwx_shellcheck_file "${target}"
+ fi
+}
diff --git a/sh/shell.sh b/sh/shell.sh
new file mode 100644
index 0000000..7e56658
--- /dev/null
+++ b/sh/shell.sh
@@ -0,0 +1,116 @@
+_rwx_shell_color() {
+ local code="${1}"
+ case "${RWX_SHELL}" in
+ "bash")
+ printf "\x01\e[0"
+ if [ -n "${code}" ]; then
+ printf "%s" ";${code}"
+ fi
+ printf "m\x02"
+ ;;
+ *)
+ printf "\033["
+ if [ -n "${code}" ]; then
+ printf "%s" "${code}"
+ else
+ printf "0"
+ fi
+ printf "m"
+ ;;
+ esac
+}
+RWX_COLOR_BROWN="$(_rwx_shell_color 33)"
+RWX_COLOR_CYAN="$(_rwx_shell_color 36)"
+RWX_COLOR_DEFAULT="$(_rwx_shell_color)"
+RWX_COLOR_GREEN="$(_rwx_shell_color 31)"
+RWX_COLOR_MAGENTA="$(_rwx_shell_color 35)"
+RWX_COLOR_RED="$(_rwx_shell_color 32)"
+
+rwx_shell_configure() {
+ [ -n "${ENV}" ] || ENV="${RWX_MAIN_PATH}"
+ export ENV
+ # prompt
+ PS1="\$(rwx_shell_prompt \${?})"
+ PS2="├ "
+ # specific
+ case "${RWX_SHELL}" in
+ "bash")
+ # completion
+ local root="/usr/share/bash-completion"
+ local file="bash_completion"
+ local path="${root}/${file}"
+ # shellcheck disable=SC1090
+ [ -f "${path}" ] && . "${path}"
+ root="${root}/completions"
+ if [ -d "${root}" ]; then
+ set \
+ "git" \
+ "tar"
+ for file in "${@}"; do
+ path="${root}/${file}"
+ # shellcheck disable=SC1090
+ [ -f "${path}" ] && . "${path}"
+ done
+ fi
+ # history
+ HISTCONTROL="ignorespace"
+ HISTSIZE=-1
+ HISTTIMEFORMAT="%Y%m%d %H%M%S "
+ ;;
+ *) ;;
+ esac
+}
+rwx_shell_configure
+
+rwx_shell_prompt() {
+ local date host id
+ local code="${1}"
+ date="$(date +%H:%M:%S)"
+ local git
+ host="$(hostname)"
+ id="$(id --user)"
+ local path="${PWD}"
+ local user="${USER}"
+ local view="╰ "
+ # code
+ if [ "${code}" -ne 0 ]; then
+ view="${view}${RWX_COLOR_GREEN}"
+ else
+ view="${view}${RWX_COLOR_RED}"
+ fi
+ view="${view}${code}"
+ # date
+ view="${view}${RWX_COLOR_DEFAULT} @ "
+ view="${view}${RWX_COLOR_BROWN}${date}"
+ # git
+ if command -v "__git_ps1" >"/dev/null"; then
+ git="$(__git_ps1)"
+ if [ -n "${git}" ]; then
+ view="${view}${RWX_COLOR_DEFAULT} –${RWX_COLOR_MAGENTA}${git}"
+ fi
+ fi
+ # new
+ view="${view}\\n"
+ # path
+ view="${view}${RWX_COLOR_CYAN}${path}"
+ # new
+ view="${view}\\n"
+ # frame
+ view="${view}${RWX_COLOR_DEFAULT}╭ "
+ # user
+ if [ "${id}" -eq 0 ]; then
+ view="${view}${RWX_COLOR_GREEN}"
+ else
+ view="${view}${RWX_COLOR_RED}"
+ fi
+ view="${view}${user}"
+ # host
+ view="${view}${RWX_COLOR_DEFAULT} @ "
+ view="${view}${RWX_COLOR_BROWN}${host}"
+ # new
+ view="${view}\\n"
+ # prompt
+ view="${view}${RWX_COLOR_DEFAULT}${PS2}"
+ # print
+ printf "%b" "${view}"
+}
diff --git a/sh/tmux.sh b/sh/tmux.sh
new file mode 100644
index 0000000..d9813e1
--- /dev/null
+++ b/sh/tmux.sh
@@ -0,0 +1,264 @@
+# ╭──────┬───────╮
+# │ tmux │ setup │
+# ╰──────┴───────╯
+
+rwx_tmux_setup() {
+ local file
+ file="${HOME}/.tmux.conf"
+ rwx_file_write "${file}" "\
+# ╭────────╮
+# │ option │
+# ╰────────╯
+
+# empty name for windows
+set-option -g automatic-rename-format '#{pane_current_command}'
+set-option -g automatic-rename on
+
+# first index number
+set-option -g base-index 1
+
+# display duration
+set-option -g display-time 1536
+
+# extend history limit
+set-option -g history-limit 1048576
+
+# style for messages
+set-option -g message-style bg=red,fg=white
+
+# activity monitoring
+set-window-option -g monitor-activity on
+
+# silence monitoring
+set-window-option -g monitor-silence 0
+
+# enable mouse actions
+set-option -g mouse on
+
+# prefix with ^B or F12
+set-option -g prefix C-b
+set-option -g prefix2 F12
+
+# renumber windows after closing one
+set-option -g renumber-windows on
+
+# enable title
+set-option -g set-titles on
+
+# set title to working directory
+set-option -g set-titles-string '\
+#{session_name}\
+ - \
+#{window_index}∕#{session_windows} #{window_name}\
+ - \
+#{pane_index}∕#{window_panes} #{pane_current_command}\
+'
+
+# ╭────────┬──────╮
+# │ option │ pane │
+# ╰────────┴──────╯
+
+# first index number
+set-option -g pane-base-index 1
+
+# ╭────────┬──────┬────────╮
+# │ option │ pane │ border │
+# ╰────────┴──────┴────────╯
+
+# active style
+set-option -g pane-active-border-style fg=green
+
+# regular style
+set-option -g pane-border-style fg=blue
+
+# ╭────────┬────────╮
+# │ option │ status │
+# ╰────────┴────────╯
+
+# status lines
+set-option -g status 3
+
+# background color
+set-option -g status-bg '#0D0D0D'
+
+# foreground color
+set-option -g status-fg white
+
+# line 1
+set-option -g status-format[0] '\
+#{W:\
+#[bg=##202020] #[bg=##303030]\
+#{?window_zoomed_flag,#[fg=magenta][, }\
+#[fg=yellow]#{window_index}\
+#{?window_zoomed_flag,#[fg=magenta]], }\
+ \
+#{?window_active,#[fg=green],\
+#{?window_activity_flag,#[fg=red],#[fg=blue]}}\
+#{window_name}\
+#[bg=##303030] #[bg=##202020] \
+#[bg=default] \
+}\
+#[align=right]\
+#[bg=##202020] #[bg=##303030] \
+#[fg=yellow]%H:%M:%S\
+#[bg=##303030] #[bg=##202020]\
+#{?client_prefix,#[fg=green]p, }\
+'
+
+# line 2
+set-option -g status-format[1] '\
+#{S:\
+#[bg=##202020] #[bg=##303030] \
+#{?session_many_attached,#[fg=red],\
+#{?session_attached,#[fg=magenta],#[fg=blue]}}\
+#{session_name}\
+#[bg=##303030] #[bg=##202020] \
+#[bg=default] \
+}\
+#[fg=yellow]→ #[fg=green]#{session_name} \
+#[align=right]\
+#[bg=##202020] #[bg=##303030] \
+#[fg=yellow]%Y-%m-%d\
+#[bg=##303030] #[bg=##202020] \
+'
+
+# line 3
+set-option -g status-format[2] '\
+#[fg=cyan]#{pane_current_path}\
+#[align=right]\
+#[bg=##202020] #[bg=##303030] \
+#[fg=yellow]#{host}\
+#[bg=##303030] #[bg=##202020] \
+'
+
+# line 4
+set-option -g status-format[3] '\
+#{P:\
+#[bg=##202020] #[bg=##303030] \
+#[fg=yellow]#{pane_index}\
+ \
+#{?pane_active,#[fg=green],#[fg=blue]}\
+#{pane_current_command}\
+#[bg=##303030] #[bg=##202020] \
+#[bg=default] \
+}\
+#[align=right]\
+#[bg=##202020] #[bg=##303030] \
+#{?uid,#[fg=green],#[fg=red]}\
+#{user}\
+#[bg=##303030] #[bg=##202020] \
+'
+
+# line 5
+set-option -g status-format[4] '\
+#{P:\
+#[bg=##202020] #[bg=##303030] \
+#[fg=yellow]#{pane_index}\
+ \
+#{?pane_active,#[fg=green],#[fg=blue]}\
+#{pane_width}×#{pane_height}\
+#[bg=##303030] #[bg=##202020] \
+#[bg=default] \
+}\
+#[align=right]\
+#[bg=##202020] #[bg=##303030] \
+#[fg=green]#{window_width}×#{window_height}\
+#[bg=##303030] #[bg=##202020] \
+'
+
+# refresh period
+set-option -g status-interval 1
+
+# bar location
+set-option -g status-position bottom
+
+# ╭─────╮
+# │ key │
+# ╰─────╯
+
+# detach client
+bind-key -n F6 detach-client
+
+# new window
+bind-key -n F2 new-window
+
+# select pane
+bind-key -n C-S-Down select-pane -D
+bind-key -n C-S-Left select-pane -L
+bind-key -n C-S-Right select-pane -R
+bind-key -n C-S-Up select-pane -U
+
+# status lines
+bind-key -n C-F10 set-option -g status off
+bind-key -n C-F1 set-option -g status on
+bind-key -n C-F2 set-option -g status 2
+bind-key -n C-F3 set-option -g status 3
+bind-key -n C-F4 set-option -g status 4
+bind-key -n C-F5 set-option -g status 5
+
+# switch session
+bind-key -n M-Down switch-client -n
+bind-key -n M-Up switch-client -p
+
+# switch window
+bind-key -n M-Left previous-window
+bind-key -n M-Right next-window
+
+# ╭─────┬────────╮
+# │ key │ prefix │
+# ╰─────┴────────╯
+
+# rename
+bind-key C-s command-prompt { rename-session '%%' }
+bind-key C-w command-prompt { rename-window '%%' }
+
+# split window
+bind-key h split-window -h
+bind-key v split-window -v
+
+# toggle mouse
+bind-key t set-option -g mouse \\; display-message 'mouse = #{mouse}'
+
+# reload configuration
+bind-key r source-file ${file} \\; display-message 'source-file ${file}'
+
+# swap window
+bind-key M-Left swap-window -t -1
+bind-key M-Right swap-window -t +1
+
+# ╭─────────────╮
+# │ default │
+# ╭───────────┬─────────┼─────┬───────┤
+# │ -n │ F12 │ -n │ C-b │
+# ╭───────────────────┼───────────┼─────────┼─────┼───────┤
+# │ command-prompt │ │ │ │ : │
+# │ copy-mode │ │ │ │ PPage │
+# │ detach-client │ │ │ │ d │
+# │ new-session │ │ │ │ │
+# │ new-window │ F2 │ │ │ c │
+# │ next-window │ M-Right │ │ │ n │
+# │ previous-window │ M-Left │ │ │ p │
+# │ rename-session │ │ C-s │ │ │
+# │ rename-window │ │ C-w │ │ │
+# │ resize-pane -Z │ │ │ │ z │
+# │ select-pane -D │ C-S-Down │ │ │ │
+# │ select-pane -L │ C-S-Left │ │ │ │
+# │ select-pane -R │ C-S-Right │ │ │ │
+# │ select-pane -U │ C-S-Up │ │ │ │
+# │ set -g mouse │ │ t │ │ │
+# │ set -g status off │ C-F10 │ │ │ │
+# │ set -g status on │ C-F1 │ │ │ │
+# │ set -g status 2 │ C-F2 │ │ │ │
+# │ set -g status 3 │ C-F3 │ │ │ │
+# │ set -g status 4 │ C-F4 │ │ │ │
+# │ set -g status 5 │ C-F5 │ │ │ │
+# │ source-file │ │ r │ │ │
+# │ split-window -h │ │ h │ │ % │
+# │ split-window -v │ │ v │ │ \" │
+# │ swap-window -t -1 │ │ M-Left │ │ │
+# │ swap-window -t +1 │ │ M-Right │ │ │
+# │ switch-client -n │ M-Down │ │ │ │
+# │ switch-client -p │ M-Up │ │ │ │
+# ╰───────────────────┴───────────┴─────────┴─────┴───────╯
+"
+}
diff --git a/sh/util.sh b/sh/util.sh
new file mode 100644
index 0000000..cbcd67a
--- /dev/null
+++ b/sh/util.sh
@@ -0,0 +1,76 @@
+rwx_file_append() {
+ local file="${1}"
+ local text="${2}"
+ if [ -n "${file}" ]; then
+ printf "%s" "${text}" >>"${file}"
+ fi
+}
+
+rwx_file_empty() {
+ local file="${1}"
+ if [ -n "${file}" ]; then
+ rwx_file_write "${file}" ""
+ fi
+}
+
+rwx_file_write() {
+ local file="${1}"
+ local text="${2}"
+ if [ -n "${file}" ]; then
+ printf "%s" "${text}" >"${file}"
+ fi
+}
+
+rwx_link() {
+ local link="${1}"
+ local target="${2}"
+ ln \
+ --symbolic \
+ "${target}" \
+ "${link}"
+}
+
+rwx_list_block_devices() {
+ lsblk \
+ --noempty \
+ --output "NAME,SIZE,TYPE,FSTYPE,LABEL,MOUNTPOINTS"
+}
+
+rwx_not() {
+ case "${1}" in
+ "false") echo "true" ;;
+ "true") echo "false" ;;
+ *) ;;
+ esac
+}
+
+rwx_read_passphrase() {
+ rwx_read_secret "PassPhrase: "
+}
+
+rwx_read_secret() {
+ local prompt="${1}"
+ local secret
+ printf "%s" "${prompt}" 1>&2
+ stty -echo
+ read -r secret
+ stty echo
+ echo >&2
+ echo "${secret}"
+ unset secret
+}
+
+rwx_remove() {
+ rm \
+ --force \
+ --recursive \
+ "${@}"
+}
+
+rwx_warn_wipe() {
+ local tmp
+ rwx_list_block_devices
+ printf "%s" "WIPE ${*} /?\\ OR CANCEL /!\\"
+ read -r tmp
+ rwx_log_trace "${tmp}"
+}