Inspect and edit files from the terminal with Node.js and Python
Need to check one setting in a large file, inspect a few lines around an error, or update a JSON version without opening an editor? A short terminal command can help. Node.js and Python are especially useful when the change needs logic: parse JSON, calculate a value, validate it, and write the result.
This guide focuses on WSL/Linux with Bash, then shows Windows PowerShell and CMD equivalents. You do not need a separate npm package for the Node.js examples or a Python package for the Python examples.
1. Understand where the command runs
The shell starts a program; Node or Python runs the code you pass to it:
node -e 'console.log("Hello from JavaScript")'
python3 -c 'print("Hello from Python")'
-e means “evaluate this JavaScript”; -c means “execute this Python code.” In Bash, the
outer single quotes protect the code from shell expansion. Double quotes inside the code then
belong to JavaScript or Python. Shell quoting rules differ in PowerShell and CMD; do not assume
a Bash one-liner can be pasted unchanged into those shells.
JavaScript and Python: the same job in two languages
| Task | Node.js / JavaScript | Python |
|---|---|---|
| Run inline code | node -e '...' | python3 -c '...' |
| Read a text file | fs.readFileSync(path, "utf8") | Path(path).read_text(encoding="utf-8") |
| Parse JSON text | JSON.parse(text) | json.loads(text) |
| Serialize a value as JSON | JSON.stringify(value, null, 2) | json.dumps(value, indent=2) |
| Write text to a file | fs.writeFileSync(path, text, "utf8") | Path(path).write_text(text, encoding="utf-8") |
| Print a value | console.log(value) | print(value) |
| Run a saved program | node scripts/update-version.js | python3 scripts/update_version.py |
Choose the language you and your team can maintain. For a repository that already uses
Node.js, JavaScript can be a convenient choice; Python is equally capable. fs is Node's
built-in file-system module, while Path comes from Python's standard pathlib module.
Start by checking your location and available tools:
pwd
ls -la
node --version
python3 --version
npm --version
A relative path like package.json is resolved from pwd, not necessarily from the folder
where a script was saved. In WSL, a Windows file such as C:\work\app\package.json is
usually available as /mnt/c/work/app/package.json; quote paths containing spaces. Node and
Python must be installed inside the environment in which you run them.
2. Inspect first, edit second
The simplest tools are often the best for plain text:
cat package.json # whole small file
less README.md # scroll; press q to quit
head -n 20 README.md # first 20 lines
tail -n 20 README.md # last 20 lines
sed -n '40,65p' src/app.ts # lines 40–65, inclusive
nl -ba src/app.ts | sed -n '40,65p' # show line numbers too
Search by content or file name with rg (ripgrep), if installed:
rg -n 'versionName' . # matching lines with numbers
rg -n -C 3 'versionName' src # three context lines on each side
rg -n -F 'api.example.com' src # literal text, not a regular expression
rg --files -g '*.json' # matching file names
By default rg respects ignore files and skips hidden files; use rg --hidden only when you
intend to include them. If ripgrep is unavailable, grep -n 'versionName' file.json is a
basic alternative. Avoid printing secrets to a shared terminal or log.
The same line range in Node and Python
Line numbers below are one-based and both ends are inclusive. For small text files:
node -e 'const fs = require("node:fs"); const lines = fs.readFileSync("src/app.ts", "utf8").split(/\r?\n/); for (let i = 39; i < Math.min(65, lines.length); i++) console.log(`${i + 1}: ${lines[i]}`);'
python3 -c 'from pathlib import Path; lines = Path("src/app.ts").read_text(encoding="utf-8").splitlines(); [print(f"{i}: {line}") for i, line in enumerate(lines, 1) if 40 <= i <= 65]'
These examples load the entire file into memory. For large logs, use sed, head, tail,
or stream a file instead. For example, this Python command stops after line 65:
python3 -c 'import itertools; f = open("src/app.ts", encoding="utf-8"); print("".join(itertools.islice(f, 39, 65)), end=""); f.close()'
3. Read JSON as data, not as text
Assume android-version.json contains:
{
"versionName": "1.4.9",
"versionCode": 21
}
Read just one property in Node or Python:
node -e 'const fs = require("node:fs"); const d = JSON.parse(fs.readFileSync("android-version.json", "utf8")); console.log(d.versionName);'
python3 -c 'import json; from pathlib import Path; d = json.loads(Path("android-version.json").read_text(encoding="utf-8")); print(d["versionName"])'
For a nested value, follow the property path, for example d.scripts.build in JavaScript or
d["scripts"]["build"] in Python. Missing properties and malformed JSON should be investigated
instead of silently producing a new configuration. These examples expect valid JSON, not
JSON with comments or trailing commas.
Inspect package.json with npm
Run from the package directory:
npm pkg get author private scripts
npm pkg get 'scripts.build'
npm pkg get reads the local package.json and prints selected values as JSON. It is an npm
command, not a general JSON reader. For workspaces, npm can select packages with its workspace
options; check npm help pkg for the installed npm version.
4. A safe workflow for a write
Commands that call writeFileSync, write_text, Set-Content, or redirect with > can
overwrite data. Work on a known file and verify the outcome:
- Check the path and current content with
pwd,ls, and a read-only command. - Check for existing changes with
git status --shortandgit diff -- path/to/fileif the file is tracked. Preserve changes already made by other people or tools. - Copy important files to a new, non-existing backup path or commit your work first.
For example, after checking that
android-version.json.bakdoes not exist, usecp -n android-version.json android-version.json.bak.-navoids overwriting a backup. - Preview the proposed value before writing it. Confirm that only the intended file is targeted.
- Apply the change once, inspect the file, and compare with
git diff -- android-version.json(ordiff -u android-version.json.bak android-version.json).
Do not use recursive edits or overwrite generated, shared, or production files without knowing the consequences. A script can fail partway through a write; for important files use a tested temporary-file-and-rename workflow, backups, and appropriate concurrency controls.
5. Increment a JSON version with Node.js
Suppose the intended rule is “increase the last numeric component of a dot-separated
version.” Thus 1.4.9 becomes 1.4.10. This is a simple numeric rule, not a complete semantic
versioning implementation: prereleases such as 1.4.9-beta are deliberately rejected below.
For example, 1.0.24 becomes 1.0.25; this is an illustration, not a statement about any
repository's current version. The JSON property versionCode is not incremented by this
command. Check your project's release process to determine whether it must change separately.
For a LocalTell checkout in WSL, first navigate to its actual root—for example, if it is on the Windows C: drive at this location:
cd /mnt/c/AR_Files/code/local-tell
pwd
cat android-version.json
If your checkout is elsewhere, use that path instead. Check the file's current value rather than relying on a version number in an old example.
First preview the result without writing:
node -e 'const fs = require("node:fs"); const d = JSON.parse(fs.readFileSync("android-version.json", "utf8")); const s = d.versionName; if (typeof s !== "string" || !/^\d+(\.\d+)*$/.test(s)) throw new Error("Expected dot-separated numeric versionName"); const v = s.split("."); v[v.length - 1] = String(BigInt(v[v.length - 1]) + 1n); console.log(`${s} -> ${v.join(".")}`);'
After reviewing and making a backup, apply it:
node -e 'const fs = require("node:fs"); const p = "android-version.json"; const d = JSON.parse(fs.readFileSync(p, "utf8")); const s = d.versionName; if (typeof s !== "string" || !/^\d+(\.\d+)*$/.test(s)) throw new Error("Expected dot-separated numeric versionName"); const v = s.split("."); v[v.length - 1] = String(BigInt(v[v.length - 1]) + 1n); d.versionName = v.join("."); fs.writeFileSync(p, JSON.stringify(d, null, 2) + "\n", "utf8"); console.log("New versionName:", d.versionName);'
How to build this command:
readFileSyncreads UTF-8 text;JSON.parseturns it into a JavaScript object.- Validate the property, then
split(".")makes an array of components. BigInt(...) + 1nincrements the last component without floating-point precision loss.join(".")builds the new version;JSON.stringify(d, null, 2)formats the object.writeFileSyncreplaces the entire file, and the last newline keeps the file tidy.
To see the same logic without squeezing it onto one line, here is the JavaScript portion as a readable program. Do not run it until you have inspected and backed up the target file:
const fs = require('node:fs');
const path = 'android-version.json';
const data = JSON.parse(fs.readFileSync(path, 'utf8'));
const current = data.versionName;
if (typeof current !== 'string' || !/^\d+(\.\d+)*$/.test(current)) {
throw new Error('Expected dot-separated numeric versionName');
}
const parts = current.split('.');
const last = parts.length - 1;
parts[last] = String(BigInt(parts[last]) + 1n);
data.versionName = parts.join('.');
fs.writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8');
console.log('New versionName:', data.versionName);
This is the same sequence as the one-liner: read → parse → validate → change → serialize →
write. If saved as scripts/update-version.cjs, run it from the repository root with
node scripts/update-version.cjs. The .cjs extension ensures that require works even if
the project configures .js files as ES modules.
After applying either version of the command, verify both properties and the file diff:
cat android-version.json
git diff -- android-version.json
The diff shows tracked changes relative to the Git index, so it may also include edits that
were already present. Compare against the backup to isolate your latest edit. If the file is
untracked, git diff will not show its contents; inspect the file and compare it with the
backup using diff -u instead.
The command reformats all JSON and normalizes line endings, so the diff may show more than the changed property. It also removes leading zeros from the last component. It is not safe to run twice accidentally: each run increments again. Two simultaneous writers can also lose updates. For a project with versioning rules or concurrent releases, use its release tooling.
6. Do the same thing with Python
Preview:
python3 -c 'import json,re; from pathlib import Path; d=json.loads(Path("android-version.json").read_text(encoding="utf-8")); s=d["versionName"]; assert isinstance(s,str) and re.fullmatch(r"[0-9]+(?:\.[0-9]+)*",s), "Expected dot-separated numeric versionName"; v=s.split("."); v[-1]=str(int(v[-1])+1); print(s,"->",".".join(v))'
Apply after checking the preview and making a backup:
python3 -c 'import json,re; from pathlib import Path; p=Path("android-version.json"); d=json.loads(p.read_text(encoding="utf-8")); s=d["versionName"]; assert isinstance(s,str) and re.fullmatch(r"[0-9]+(?:\.[0-9]+)*",s), "Expected dot-separated numeric versionName"; v=s.split("."); v[-1]=str(int(v[-1])+1); d["versionName"]=".".join(v); p.write_text(json.dumps(d,indent=2,ensure_ascii=False)+"\n",encoding="utf-8"); print("New versionName:",d["versionName"])'
Path.read_text and write_text deal with text; json.loads and json.dumps convert between
JSON text and Python values. v[-1] is the last list item. This example uses assert as a
compact interactive check; do not rely on assert for production validation because Python
can disable assertions in optimized mode. Like the Node version, this rewrites the JSON file.
7. More useful programming recipes
Print matching lines with their numbers
node -e 'const fs = require("node:fs"); fs.readFileSync("README.md", "utf8").split(/\r?\n/).forEach((line, i) => { if (line.includes("install")) console.log(`${i + 1}: ${line}`); });'
python3 -c 'from pathlib import Path; lines=Path("README.md").read_text(encoding="utf-8").splitlines(); [print(f"{i}: {line}") for i,line in enumerate(lines,1) if "install" in line]'
These searches are case-sensitive and literal. Prefer rg -n -F 'install' README.md for
routine searching; use code when you need extra conditions or structured output.
List JSON object keys
node -e 'const fs = require("node:fs"); console.log(Object.keys(JSON.parse(fs.readFileSync("package.json", "utf8"))).join("\n"));'
python3 -c 'import json; from pathlib import Path; d=json.loads(Path("package.json").read_text(encoding="utf-8")); print("\n".join(d.keys()))'
Validate JSON without changing it
node -e 'const fs = require("node:fs"); JSON.parse(fs.readFileSync("package.json", "utf8")); console.log("Valid JSON");'
python3 -m json.tool package.json > /dev/null && echo 'Valid JSON'
The redirection sends the pretty-printed JSON to /dev/null, not back to the input file. A
parse error causes a nonzero exit status. Never redirect a program's output to its own input
file: the shell can truncate it before the program reads it.
Preview a text replacement
node -e 'const fs = require("node:fs"); const s = fs.readFileSync("README.md", "utf8"); console.log(s.replaceAll("old.example.com", "new.example.com"));'
This prints a proposed result; it does not edit the file. Review occurrences first with
rg -n -F 'old.example.com' README.md. If you later write a replacement, check the expected
match count, keep a backup, and verify the diff. Plain text replacement is not a substitute for
parsing structured formats.
Pass a file name as an argument
Keeping a path separate from code avoids constructing code from user input:
node -e 'const fs = require("node:fs"); console.log(fs.readFileSync(process.argv[1], "utf8"));' -- 'my folder/notes.txt'
python3 -c 'import sys; from pathlib import Path; print(Path(sys.argv[1]).read_text(encoding="utf-8"), end="")' 'my folder/notes.txt'
The quotes make the path one shell argument. For Node -e, the first supplied argument is
process.argv[1]; for Python -c, it is sys.argv[1]. Never paste untrusted text into code
and then evaluate it.
8. When a one-liner becomes a script
One-liners are convenient for a quick, local task. Once you need branches, error handling, tests,
or repeated use, put the code in a named .js or .py file, review it, and run it with
node script.js or python3 script.py. Add a dry-run option, clear usage, and a nonzero exit
code for invalid input. A readable script is easier to review and safer to share.
9. PowerShell and CMD equivalents
PowerShell has its own object pipeline and quoting rules. The following examples use PowerShell syntax, not Bash syntax:
Get-Location
Get-Content -LiteralPath 'README.md' -TotalCount 20
Get-Content -LiteralPath 'README.md' | Select-Object -Skip 39 -First 26
Select-String -LiteralPath 'README.md' -Pattern 'install' -SimpleMatch -Context 3,3
Get-Content -LiteralPath 'package.json' -Raw | ConvertFrom-Json | Select-Object author, private, scripts
npm pkg get author private scripts
-Skip 39 -First 26 selects lines 40–65. -Raw reads JSON as one string. PowerShell's
ConvertFrom-Json produces objects; ConvertTo-Json can rewrite a file but may change
formatting, ordering, and encoding. Preview and back up before using Set-Content.
For Node/Python code in PowerShell, prefer a small script file for anything nontrivial. Native argument quoting varies between Windows PowerShell and newer PowerShell releases. A simple PowerShell-native inspection is often easier:
$data = Get-Content -LiteralPath 'android-version.json' -Raw | ConvertFrom-Json
$data.versionName
In classic Windows CMD, type README.md prints a file and findstr /n /c:"install" README.md
finds literal lines. npm pkg get author private scripts works there too. CMD uses different
escaping rules again; for longer Node/Python code, use a script file rather than adapting Bash
single-quoted one-liners by guesswork.
Quick reference
| Goal | Useful starting point |
|---|---|
| Inspect one range | sed -n '40,65p' file |
| Find text and context | rg -n -C 3 -F 'text' file |
| Inspect package metadata | npm pkg get author private scripts |
| Parse JSON in JavaScript | JSON.parse(fs.readFileSync(path, 'utf8')) |
| Parse JSON in Python | json.loads(Path(path).read_text(encoding='utf-8')) |
| Apply a calculated edit | Validate, preview, back up, write, then inspect the diff |
The key habit is simple: read and validate first, change one known file, then verify.
10. node -p vs node -e vs npm pkg
All three can inspect a package name, but they are different tools—not interchangeable in every situation:
| Command | Purpose | When to use it |
|---|---|---|
node -p 'expression' | Evaluate a JavaScript expression and print its result. | A quick calculation or reading one JSON property. |
node -e 'code' | Execute JavaScript code; print explicitly with console.log. | Multiple statements, conditionals, transformations, or file operations. |
npm pkg get field | Read fields from a package's package.json using npm. | Inspect npm package metadata, scripts, or workspace packages. |
For example, from a repository root, inspect the name in a built package:
node -p "require('./dist/libs/test/package.json').name"
node -e "console.log(require('./dist/libs/test/package.json').name)"
-p is short for --print; -e is short for --eval. The first prints the expression's
value automatically; the second requires console.log to display it. require can load JSON
directly. The file path is resolved relative to the evaluated code's current working directory;
check with pwd. require caches loaded modules within a Node process, so if a long-running
program edits and rereads the same file, prefer reading it explicitly with fs and parsing it.
For npm, target the package directory explicitly, for example by running npm inside it:
(cd ./dist/libs/test && npm pkg get name)
Parentheses create a Bash subshell, so the original terminal stays in its current directory.
Or, from the package directory itself, run npm pkg get name. npm prints a JSON value such as
"test", while the Node examples print the string test without JSON quotes. Structured values,
missing fields, output formatting, and exit behavior can also differ; these are similar ways
to inspect a value, not byte-for-byte equivalents. npm pkg get requires a valid npm package
and operates on its package.json; it is not a general-purpose reader for arbitrary JSON files.
Node and Python can read an arbitrary JSON file directly:
node -p 'JSON.parse(require("node:fs").readFileSync("android-version.json", "utf8")).versionName'
node -e 'const fs = require("node:fs"); const d = JSON.parse(fs.readFileSync("android-version.json", "utf8")); console.log(d.versionName);'
python3 -c 'import json; from pathlib import Path; print(json.loads(Path("android-version.json").read_text(encoding="utf-8"))["versionName"])'
Do not confuse npm pkg get with npm run: the former reads the declared script value; the
latter executes a script. npm also has commands that modify package metadata, so check the
subcommand before running it.
11. Check which files exist at a location
These examples are read-only. Substitute your actual path and quote paths with spaces:
ls -la 'dist/libs/test' # list this directory
ls -ld 'dist/libs/test' # inspect the directory itself
test -f 'dist/libs/test/package.json' && echo 'File exists'
test -d 'dist/libs/test' && echo 'Directory exists'
rg --files 'dist/libs/test' # files visible to ripgrep, recursively
find 'dist/libs/test' -maxdepth 2 -type f -name '*.json'
test -f checks for a regular file; test -d checks for a directory. A missing item makes
test return a nonzero exit status. rg --files observes ignore and hidden-file rules, so it
is not an exhaustive inventory by default; find searches the specified tree, including
hidden entries, subject to permissions. ls includes hidden entries with -a.
Node and Python can check a path too:
node -e 'const fs = require("node:fs"); console.log(fs.existsSync("dist/libs/test/package.json") ? "Exists" : "Missing");'
python3 -c 'from pathlib import Path; p=Path("dist/libs/test/package.json"); print("File exists" if p.is_file() else "Missing or not a regular file")'
existsSync only checks existence: use fs.statSync(path).isFile() when you need to distinguish
a file from a directory, and handle missing paths. A file can change between checking and using
it, so real programs must still handle errors during the actual read or write.
12. Replace exactly one complete line
Suppose settings.txt contains a line that is exactly old setting and should become
new setting. The following examples reject zero or multiple matches, replace the complete
line only, and preserve existing LF or CRLF line endings. They write to the file: inspect
it, create a backup, and review the values first. Do not use these for binary files or as a
substitute for parsing JSON, YAML, or another structured format.
Node.js, as a multiline Bash heredoc:
node <<'NODE'
const fs = require('node:fs');
const path = 'settings.txt';
const oldLine = 'old setting';
const newLine = 'new setting';
const original = fs.readFileSync(path, 'utf8');
const lines = original.split(/(\r?\n)/); // Keep the line separators.
const matches = lines.filter((part, index) => index % 2 === 0 && part === oldLine).length;
if (matches !== 1) throw new Error(`Expected one matching line; found ${matches}`);
for (let i = 0; i < lines.length; i += 2) {
if (lines[i] === oldLine) lines[i] = newLine;
}
fs.writeFileSync(path, lines.join(''), 'utf8');
console.log('Replaced one line in', path);
NODE
Python equivalent, preserving line endings by working with bytes:
python3 - <<'PY'
from pathlib import Path
path = Path('settings.txt')
old_line = b'old setting'
new_line = b'new setting'
original = path.read_bytes()
lines = original.splitlines(keepends=True)
def content(line):
return line.removesuffix(b'\n').removesuffix(b'\r')
matches = sum(content(line) == old_line for line in lines)
if matches != 1:
raise ValueError(f'Expected one matching line; found {matches}')
updated = []
for line in lines:
body = content(line)
ending = line[len(body):]
updated.append((new_line if body == old_line else body) + ending)
path.write_bytes(b''.join(updated))
print('Replaced one line in', path)
PY
The Python example uses Python 3.9 or later for removesuffix. Its byte values are UTF-8
compatible for the ASCII strings shown; encode non-ASCII values explicitly. Both examples
assume ordinary LF/CRLF text; test unusual encodings and line endings separately. Afterwards:
git diff -- settings.txt
diff -u settings.txt.bak settings.txt # if you created this backup
The commands write in place, not atomically; they are not suitable for concurrent writers.
13. Multiline programs directly in Bash: heredocs
A here-document, or heredoc, sends multiple lines to a command's standard input. With a
quoted delimiter such as <<'PY', Bash does not expand $variables, backticks, or command
substitutions inside the body. The terminating word must appear alone, with no indentation.
These examples are Bash/WSL syntax, not PowerShell or CMD syntax.
Update a JSON number with Python
This changes versionCode to 31 and leaves the versionName value unchanged. It overwrites
the JSON file and reformats it, so check the current values and create a backup first:
python3 - <<'PY'
import json
from pathlib import Path
path = Path('android-version.json')
data = json.loads(path.read_text(encoding='utf-8'))
data['versionCode'] = 31
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
print(path.read_text(encoding='utf-8'))
PY
Equivalent JavaScript using Node's standard input:
node <<'NODE'
const fs = require('node:fs');
const path = 'android-version.json';
const data = JSON.parse(fs.readFileSync(path, 'utf8'));
data.versionCode = 31;
fs.writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8');
console.log(fs.readFileSync(path, 'utf8'));
NODE
31 is a JSON number, not the string "31". The example sets a fixed value; choose the value
required by your release process. Python's and Node's serializers may format or escape JSON
slightly differently. Both rewrite the entire file. Always inspect the diff.
Update a JSON string using Python file objects
This alternative uses json.load and json.dump directly with file handles. Opening with w
truncates the file, so it is a write operation and needs the same backup and review:
python3 - <<'PY'
import json
path = 'android-version.json'
with open(path, encoding='utf-8') as file:
data = json.load(file)
data['versionName'] = '1.0.22' # Example only; choose your intended version.
with open(path, 'w', encoding='utf-8') as file:
json.dump(data, file, indent=2, ensure_ascii=False)
file.write('\n')
PY
The equivalent complete Node program is:
node <<'NODE'
const fs = require('node:fs');
const path = 'android-version.json';
const data = JSON.parse(fs.readFileSync(path, 'utf8'));
data.versionName = '1.0.22'; // Example only; choose your intended version.
fs.writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8');
NODE
Both examples overwrite the file. json.load(file) reads from a file object, while
json.loads(text) reads a string; dump writes to a file and dumps returns a string.
Create a multiline text file
The shell can send literal text to cat, whose output is redirected into a file:
cat > /tmp/example-release-notes.md <<'EOF'
# Example release notes
Initial geographic data release.
## Coverage
- 9 province packs
- 25 districts
The text `$HOME` and `backticks` remain literal in this quoted heredoc.
EOF
> creates the file or truncates an existing one immediately. First check the exact path
and ensure it does not exist; use set -C (Bash noclobber) or a different new path if you
must prevent accidental overwrite. /tmp is temporary and may be cleaned by the system; choose
a project path for material that must be retained. >> appends instead of replacing, but can
duplicate content if repeated. Inspect with cat /tmp/example-release-notes.md.
14. What do these command names mean?
| Name | Meaning or origin | Common use |
|---|---|---|
sed | Stream editor | Select or transform text streams, e.g. sed -n '40,65p' file. |
nl | Number lines | Print text with line numbers; -ba numbers blank lines too. |
rg | Command name for ripgrep | Search files quickly; it is not a separate shell feature. |
grep | Global regular expression print | Search for lines matching a pattern. |
cat | Concatenate | Print or combine files; it can also receive standard input. |
ls | List | List directory entries. |
pwd | Print working directory | Show the shell's current directory. |
cp | Copy | Copy a file or directory. |
diff | Difference | Compare files; -u produces a unified diff. |
head / tail | First / last part of input | Inspect the beginning or end of a file. |
find | Find filesystem entries | Search a directory tree by name, type, and other conditions. |
rg is the executable name of ripgrep, not an official expansion that needs to be memorized.
Its defaults exclude ignored and hidden content; deliberately opt in when your task needs it.
15. WSL paths, realpath, and finding skill files
Windows paths, WSL paths, and application settings do not always follow the same rules. A
directory such as C:\repos\ai-automation\ar_solutions\skills is commonly available in WSL at
/mnt/c/repos/ai-automation/ar_solutions/skills. The actual Windows path, drive mount, WSL user,
and application environment can differ. Run these examples in the WSL terminal that owns the
files; substitute your own path.
See the full path with realpath
pwd
printf 'Home: %s\n' "$HOME"
realpath .
realpath /mnt/c/repos/ai-automation/ar_solutions/skills
realpath resolves a path to an absolute, canonical path, processing . and .. and
resolving symbolic links. Depending on options and implementation, missing components may
cause an error; for an existing directory, check that the path exists and is accessible first.
realpath verifies filesystem resolution—not whether an application accepts, reads, or trusts
that directory.
In a Bash command, ~ at the start of an unquoted path expands to your home directory:
printf '%s\n' ~/projects
printf '%s\n' "$HOME/projects" # useful when a path may contain spaces
Do not write "~/projects" expecting Bash to expand ~: inside quotes it stays literal.
Use "$HOME/projects" when quoting is needed. This is a shell rule; ~ inside a JSON string
is just text unless the application interprets it itself.
Understand ~/../../mnt/c
If—and only if—your WSL home is /home/actionanand, this path resolves as follows:
~ -> /home/actionanand
~/.. -> /home
~/../.. -> /
~/../../mnt/c -> /mnt/c
Check your own result rather than assuming the same home-directory depth:
printf 'HOME=%s\n' "$HOME"
realpath ~/../../mnt/c/repos/ai-automation/ar_solutions/skills
realpath /mnt/c/repos/ai-automation/ar_solutions/skills
If both commands print the same path, the filesystem resolves them to the same location. This
does not prove that a VS Code setting will accept or discover skills there. A path such as
~/../../mnt/c/... has been used as a workaround in some setups, but it relies on the home
location and the application's handling of ~ and ... Treat it as an experiment, not a
portable or officially guaranteed configuration. WSL, local Windows, and remote VS Code
sessions may each have different home directories.
Find only the files you need
Given this structure:
skills/
├── documentation-writer/
│ └── SKILL.md
└── project-documentation/
└── SKILL.md
Use find with a quoted name pattern:
SKILLS="/mnt/c/repos/ai-automation/ar_solutions/skills"
ls -la "$SKILLS"
find "$SKILLS" -maxdepth 2 -type f -name 'SKILL.md' -print
sed -n '1,15p' "$SKILLS/documentation-writer/SKILL.md"
sed -n '1,15p' "$SKILLS/project-documentation/SKILL.md"
The starting directory is depth 0, each skill folder is depth 1, and its SKILL.md is depth 2.
-type f selects regular files, -name matches the file name, and -print prints paths. Quote
'*.md' or 'SKILL.md' so Bash does not expand the pattern before find sees it. For example:
find "$SKILLS" -maxdepth 2 -type f -name '*.md' -print
find "$HOME/.copilot/skills" -maxdepth 2 -type f -name 'SKILL.md' -print
If the user-level directory does not exist yet, find reports an error; check with
test -d "$HOME/.copilot/skills" first. find normally does not follow directory symbolic
links; do not add links or use -L blindly. Inspect the target and the application's policy.
Filesystem discovery versus VS Code skill discovery
An absolute /mnt/c/... path may exist in WSL yet be rejected by an application setting.
Conversely, a setting that accepts a path does not guarantee that it loads a skill. The
previously used chat.agentSkillsLocations setting is now deprecated and applies to the
VS Code Local harness, not the Copilot harness; support can change with VS Code versions. The
VS Code agent skills guide
documents supported workspace and user locations, and the
migration guide
explains moving custom-location skills to supported locations. Check those guides for your
installed version and selected agent before changing settings.
For troubleshooting:
- Verify the path and the first lines of each
SKILL.mdwith the commands above. - Confirm that VS Code is connected to the intended WSL environment and agent/harness; its home directory may differ from the terminal you tested.
- Check the skill's required front matter, folder name, and discovery location against the
current VS Code documentation. In supported versions,
/skillsopens the skill configuration menu. Reload the window if needed and inspect the available skills. - If the files exist but do not appear, investigate skill loading separately from filesystem path resolution. Avoid claiming a particular workaround or slash command will always work.
Copy skills into a user-level directory, deliberately
If you choose the documented user-level ~/.copilot/skills location, copying creates separate
copies: later edits to the original and copied folders do not synchronize. Review the
source skill files first, including any referenced scripts, because a skill can instruct an
agent to take actions. Before copying, check for an existing destination and do not overwrite
or merge someone else's skill folder.
SKILLS="/mnt/c/repos/ai-automation/ar_solutions/skills"
DEST="$HOME/.copilot/skills"
ls -la "$SKILLS"
ls -ld "$DEST" 2>/dev/null || printf 'Destination does not exist yet\n'
After verifying the source and destination, create the parent directory and copy only if the named destination does not already exist:
mkdir -p "$DEST"
for skill in documentation-writer project-documentation; do
if [ ! -d "$SKILLS/$skill" ]; then
printf 'Missing source: %s\n' "$SKILLS/$skill" >&2
elif [ -e "$DEST/$skill" ] || [ -L "$DEST/$skill" ]; then
printf 'Skipping existing destination: %s\n' "$DEST/$skill" >&2
else
cp -a "$SKILLS/$skill" "$DEST/"
fi
done
find "$DEST" -maxdepth 2 -type f -name 'SKILL.md' -print
mkdir -p creates missing parent directories; cp -a copies each directory and preserves
attributes where supported. This check helps avoid accidental replacement in ordinary use; it
is not an atomic safeguard against another process changing the destination concurrently.
Verify the copies and then use the supported VS Code discovery workflow. No copying is required
just to inspect paths.