christiangeorgelucas/config-tools
v0.1.1TypeScriptThe config-tools package provides a deterministic way to parse, serialize, and edit configuration files in .env, INI, and Java .properties formats. It utilizes a canonical representation (ConfigDocument) to enable seamless editing and merging of configuration data without regard to the original format, making it ideal for applications that require consistent configuration management across different file types.
Published by Christian George Lucas· MIT
Use cases
- •Parse .env files for application configuration
- •Convert INI files to Java .properties format
- •Merge multiple configuration documents into one
- •Expand environment variables within configuration files
- •Validate the structure of configuration files
Nodes (14)
The ConvertConfig node transforms configuration text from one specified format to another by first parsing the input text into a canonical ConfigDocument and then serializing it into the desired output format. It ensures that no information is lost during the conversion, particularly when handling sectioned INI entries.
View source
import { ConvertConfigRequest, ConvertConfigResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import {
parseByFormat,
serializeByFormat,
flattenSections,
ConfigNodeError,
} from './helpers';
/**
* Converts config text from one format to another: parses `text` as
* `from_format` into the canonical ConfigDocument, then serializes it as
* `to_format`. Converting a sectioned INI entry into an unsectioned format
* (env or properties) dot-joins its key as "section.key" so no information
* is silently dropped — see the field docs on ConvertConfigRequest for the
* exact rule and why it is not reversible. `from_format`/`to_format` must
* each be one of "env" | "ini" | "properties"; any other value is
* INVALID_ARGUMENT.
*/
export function convertConfig(ax: AxiomContext, input: ConvertConfigRequest): ConvertConfigResult {
const result = new ConvertConfigResult();
const text = input.getText();
try {
const fromFormat = input.getFromFormat();
const toFormat = input.getToFormat();
let entries = parseByFormat(text, fromFormat);
if (toFormat !== 'ini') {
entries = flattenSections(entries);
}
result.setText(serializeByFormat(entries, toFormat, input.getSortKeys()));
} catch (err) {
if (err instanceof ConfigNodeError) {
result.setError(err.proto);
return result;
}
throw err;
}
return result;
}
The DeleteConfigValue node processes a request to remove specific entries from a configuration document based on the provided section and key. It returns an updated configuration document and indicates whether any entries were actually removed.
View source
import { DeleteConfigValueRequest, DeleteConfigValueResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { docToEntries, entriesToDoc, deleteConfigValue as deleteConfigValueImpl } from './helpers';
/**
* Returns a new ConfigDocument with every entry matching (section, key)
* removed (there is normally at most one; any duplicate is also removed),
* and whether an entry was actually removed. Returns the document unchanged
* (found=false) if no such entry existed.
*/
export function deleteConfigValue(ax: AxiomContext, input: DeleteConfigValueRequest): DeleteConfigValueResult {
const result = new DeleteConfigValueResult();
const entries = docToEntries(input.getDocument());
const { entries: updated, found } = deleteConfigValueImpl(entries, input.getKey(), input.getSection());
result.setDocument(entriesToDoc(updated));
result.setFound(found);
return result;
}
The DetectFormat node classifies input text into one of four categories: 'env', 'ini', 'properties', or 'unknown' based on specific structural signals present in the text. It uses a hierarchy of unique indicators to determine the most likely format, returning 'unknown' when no recognizable signals are detected.
View source
import { DetectFormatInput, DetectFormatResult, ConfigFormatScore } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { detectFormat as detectFormatImpl } from './helpers';
/**
* Classifies text as "env" | "ini" | "properties" | "unknown" from
* structural signals: a "[section]" header line is a strong, ini-unique
* signal; a leading "export KEY=" line is a strong, env-unique signal; a
* "!" comment line or a trailing unescaped line-continuation backslash is a
* strong, properties-unique signal. All three formats otherwise accept
* plain "key=value" lines, which is the weakest signal and does not
* strongly favor any one format. Ties are broken ini > properties > env
* (most-to-least structurally distinctive). Returns format="unknown" with
* confidence 0 when no line has a section header, an export/quoted-value
* prefix, a "!"/"#"/";" comment marker, a continuation backslash, or a
* "="/":" separator. NOTE: java.util.Properties' bare whitespace-only
* "key value" separator (no "=", ":", or other marker) is NOT scored as a
* signal here — it's indistinguishable from ordinary prose by shape alone
* — even though ParseProperties does accept it; see helpers.ts detectFormat.
*/
export function detectFormat(ax: AxiomContext, input: DetectFormatInput): DetectFormatResult {
const result = new DetectFormatResult();
const text = input.getText();
const { format, confidence, candidates } = detectFormatImpl(text);
result.setFormat(format);
result.setConfidence(confidence);
result.setCandidatesList(
candidates.map((c) => {
const s = new ConfigFormatScore();
s.setFormat(c.format);
s.setConfidence(c.confidence);
return s;
})
);
return result;
}
The ExpandEnvVars node processes a ConfigDocument by expanding references to environment variables formatted as '${VAR}', resolving them against the document's own entries. It supports recursive expansion up to a depth of 50 and detects cycles in references, returning unresolved variables when no matching key is found.
View source
import { ExpandEnvVarsRequest, ExpandEnvVarsResult, UnresolvedRef } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { docToEntries, entriesToDoc, expandEnvVars as expandEnvVarsImpl, ConfigNodeError } from './helpers';
/**
* Expands "${VAR}" references inside a ConfigDocument's own values,
* resolving each VAR against the SAME document's entries (by key) — never
* the host process's real environment. Expansion is recursive (a resolved
* value may itself contain further "${VAR}" references) up to a depth of
* 50, and a reference cycle (A -> B -> A) is rejected with CYCLE_DETECTED
* instead of looping. A "${VAR}" with no matching key expands to "" and is
* also listed in `unresolved` so the caller can distinguish "empty on
* purpose" from "undefined reference."
*/
export function expandEnvVars(ax: AxiomContext, input: ExpandEnvVarsRequest): ExpandEnvVarsResult {
const result = new ExpandEnvVarsResult();
const entries = docToEntries(input.getDocument());
try {
const { entries: expanded, unresolved } = expandEnvVarsImpl(entries);
result.setDocument(entriesToDoc(expanded));
result.setUnresolvedList(
unresolved.map((u) => {
const ref = new UnresolvedRef();
ref.setEntryKey(u.entryKey);
ref.setVarName(u.varName);
return ref;
})
);
} catch (err) {
if (err instanceof ConfigNodeError) {
result.setError(err.proto);
return result;
}
throw err;
}
return result;
}
The GetConfigValue node retrieves the value associated with a specific (section, key) pair from a ConfigDocument, ensuring that if duplicates exist, the last entry is returned. If the entry is not found, it returns a default value indicating absence.
View source
import { GetConfigValueRequest, GetConfigValueResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { docToEntries, getConfigValue as getConfigValueImpl } from './helpers';
/**
* Looks up a single (section, key) entry's value in a ConfigDocument. When
* the same (section, key) appears more than once, the LAST entry wins
* (matching .env/.properties/INI's own duplicate-key semantics). `found` is
* false, and `value` is "", when no such entry exists.
*/
export function getConfigValue(ax: AxiomContext, input: GetConfigValueRequest): GetConfigValueResult {
const result = new GetConfigValueResult();
const entries = docToEntries(input.getDocument());
const { value, found } = getConfigValueImpl(entries, input.getKey(), input.getSection());
result.setValue(value);
result.setFound(found);
return result;
}
The MergeConfig node combines two configuration documents by preserving the order of the base document while overriding its entries with those from the overlay document when applicable. Entries unique to the overlay are appended after all entries from the base document.
View source
import { MergeConfigRequest, MergeConfigResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { docToEntries, entriesToDoc, mergeConfig as mergeConfigImpl } from './helpers';
/**
* Merges two ConfigDocuments: `base`'s entries in `base`'s order, with any
* (section, key) also present in `overlay` overridden by overlay's value
* (overlay's own last occurrence wins if it has internal duplicates); any
* (section, key) present only in `overlay` is appended, in overlay's order,
* after all of `base`'s entries.
*/
export function mergeConfig(ax: AxiomContext, input: MergeConfigRequest): MergeConfigResult {
const result = new MergeConfigResult();
const base = docToEntries(input.getBase());
const overlay = docToEntries(input.getOverlay());
result.setDocument(entriesToDoc(mergeConfigImpl(base, overlay)));
return result;
}
The ParseEnv node processes .env (dotenv) formatted text and converts it into a ConfigDocument using the `dotenv` library's parser, which handles various key-value pair formats and ignores unparseable lines without erroring out.
View source
import { ParseEnvRequest, ParseEnvResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { entriesToDoc, parseEnvText } from './helpers';
/**
* Parses .env (dotenv) text into a ConfigDocument. Wraps the `dotenv`
* library's own parser directly: KEY=value lines, an optional leading
* "export ", single/double/backtick-quoted values, "\n"/"\r" escapes inside
* double-quoted values, and multi-line double-quoted values. dotenv is
* deliberately permissive — a line it cannot parse as a key/value pair is
* silently skipped rather than rejected, so this node never rejects input.
*/
export function parseEnv(ax: AxiomContext, input: ParseEnvRequest): ParseEnvResult {
const result = new ParseEnvResult();
const text = input.getText();
const entries = parseEnvText(text);
result.setDocument(entriesToDoc(entries));
return result;
}
The ParseIni node processes INI formatted text and converts it into a structured ConfigDocument using the `ini` library. It handles sections, comments, and repeated keys while enforcing a single-level section hierarchy.
View source
import { ParseIniRequest, ParseIniResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { entriesToDoc, parseIniText, ConfigNodeError } from './helpers';
/**
* Parses INI text into a ConfigDocument, via the `ini` library. "[section]"
* headers open a section; keys before any header get section "". Both ";"
* and "#" start a comment line. A "key[]=value" repeated-key line becomes
* multiple ConfigEntry rows with the same key. A section header nested more
* than one level deep (e.g. "[a.b]") is rejected with INVALID_INPUT —
* ConfigEntry supports exactly one section level.
*/
export function parseIni(ax: AxiomContext, input: ParseIniRequest): ParseIniResult {
const result = new ParseIniResult();
const text = input.getText();
try {
const entries = parseIniText(text);
result.setDocument(entriesToDoc(entries));
} catch (err) {
if (err instanceof ConfigNodeError) {
result.setError(err.proto);
return result;
}
throw err;
}
return result;
}
The ParseProperties node converts Java .properties text into a ConfigDocument using the `dot-properties` library, adhering to the parsing rules of java.util.Properties#load. It handles comments, various key-value separators, line continuations, and unicode escapes, ensuring that it never rejects any input.
View source
import { ParsePropertiesRequest, ParsePropertiesResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { entriesToDoc, parsePropertiesText } from './helpers';
/**
* Parses Java .properties text into a ConfigDocument, via the `dot-properties`
* library, which implements java.util.Properties#load's rules directly: "#"
* or "!" comment lines, "key=value" / "key:value" / "key value" separator
* forms, a trailing unescaped "\" continuing a logical line onto the next
* physical line, and "\uXXXX" unicode escapes in keys and values. Like
* java.util.Properties itself, this format has no rejecting grammar — any
* text produces SOME set of key/value pairs, so this node never rejects
* input.
*/
export function parseProperties(ax: AxiomContext, input: ParsePropertiesRequest): ParsePropertiesResult {
const result = new ParsePropertiesResult();
const text = input.getText();
const entries = parsePropertiesText(text);
result.setDocument(entriesToDoc(entries));
return result;
}
The SerializeEnv node converts a ConfigDocument into a .env formatted text representation, ensuring each entry is properly escaped for safe parsing. It validates variable names and rejects any that do not conform to the .env naming conventions.
View source
import { SerializeEnvRequest, SerializeEnvResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { docToEntries, serializeEnvText, ConfigNodeError } from './helpers';
/**
* Serializes a ConfigDocument to .env text — one "KEY=value" line per
* entry. A value is emitted bare when it needs no escaping, otherwise
* quoted (preferring single, then double, then backtick quotes, whichever
* the value doesn't itself contain) so the result re-parses via ParseEnv to
* the same value. A key that isn't a valid .env variable name (letters,
* digits, "_", ".", "-", not starting with a digit/./-) is rejected with
* INVALID_ARGUMENT rather than emitted as text that wouldn't parse back.
*/
export function serializeEnv(ax: AxiomContext, input: SerializeEnvRequest): SerializeEnvResult {
const result = new SerializeEnvResult();
const entries = docToEntries(input.getDocument());
try {
result.setText(serializeEnvText(entries, input.getSortKeys()));
} catch (err) {
if (err instanceof ConfigNodeError) {
result.setError(err.proto);
return result;
}
throw err;
}
return result;
}
The SerializeIni node converts a ConfigDocument into INI formatted text using the `ini` library, organizing entries with section-'' first and then each named section as a '[section]' block.
View source
import { SerializeIniRequest, SerializeIniResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { docToEntries, serializeIniText } from './helpers';
/**
* Serializes a ConfigDocument to INI text via the `ini` library:
* section-"" entries first with no header, then each named section as a
* "[section]" block.
*/
export function serializeIni(ax: AxiomContext, input: SerializeIniRequest): SerializeIniResult {
const result = new SerializeIniResult();
const entries = docToEntries(input.getDocument());
result.setText(serializeIniText(entries, input.getSortKeys()));
return result;
}
The SerializeProperties node converts a ConfigDocument into Java .properties text format, ensuring that each entry is represented as a 'key = value' line with appropriate escaping for control characters and non-Latin-1 characters.
View source
import { SerializePropertiesRequest, SerializePropertiesResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { docToEntries, serializePropertiesText } from './helpers';
/**
* Serializes a ConfigDocument to Java .properties text via `dot-properties`:
* one "key = value" line per entry (no line folding), with keys/values
* escaped per java.util.Properties#store (control characters, "=", ":",
* leading whitespace in keys, and — by default — any non-Latin-1 character
* as "\uXXXX").
*/
export function serializeProperties(ax: AxiomContext, input: SerializePropertiesRequest): SerializePropertiesResult {
const result = new SerializePropertiesResult();
const entries = docToEntries(input.getDocument());
result.setText(serializePropertiesText(entries, input.getSortKeys()));
return result;
}
The SetConfigValue node updates a configuration document by setting a specified value for a given (section, key) pair, ensuring that any previous duplicate entries are removed while the original document remains unchanged.
View source
import { SetConfigValueRequest, SetConfigValueResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { docToEntries, entriesToDoc, setConfigValue as setConfigValueImpl } from './helpers';
/**
* Returns a new ConfigDocument with the (section, key) entry's value set to
* `value`: updated in place at its existing position if that (section, key)
* already occurs (any earlier duplicate of the same pair is dropped, so the
* result has exactly one entry for it), or appended at the end if it did
* not occur. The input document is never mutated — SetConfigValueResult
* carries a distinct document.
*/
export function setConfigValue(ax: AxiomContext, input: SetConfigValueRequest): SetConfigValueResult {
const result = new SetConfigValueResult();
const entries = docToEntries(input.getDocument());
const updated = setConfigValueImpl(entries, input.getKey(), input.getValue(), input.getSection());
result.setDocument(entriesToDoc(updated));
return result;
}
The ValidateConfig node checks if the provided text is well-formed according to the specified format ('env', 'ini', or 'properties'). It ensures that 'env' and 'properties' formats are always valid, while 'ini' format is validated for nested sections and unrecognized formats return an error message.
View source
import { ValidateConfigRequest, ValidateConfigResult } from '../gen/messages_pb';
import { AxiomContext } from '../gen/axiomContext';
import { validateConfig as validateConfigImpl } from './helpers';
/**
* Checks whether `text` is well-formed in `format` ("env" | "ini" |
* "properties"). env and properties are parsed by libraries with no
* rejecting grammar at all (see ParseEnv/ParseProperties) — any text
* produces SOME set of key/value pairs — so those two formats are always
* valid=true. ini validation additionally rejects a section nested more
* than one level deep (e.g. "[a.b]"), reporting the offending line. An
* unrecognized `format` name is reported as valid=false with
* `error_message` explaining why (matching how a real syntax error is
* reported, rather than as a separate error field).
*/
export function validateConfig(ax: AxiomContext, input: ValidateConfigRequest): ValidateConfigResult {
const result = new ValidateConfigResult();
const text = input.getText();
const outcome = validateConfigImpl(text, input.getFormat());
result.setValid(outcome.valid);
result.setErrorMessage(outcome.message);
result.setLine(outcome.line);
result.setColumn(outcome.column);
return result;
}
Messages (33)
Download .protoThe configuration text to be converted.
The format of the input configuration text, which must be one of 'env', 'ini', or 'properties'.
The desired output format for the configuration text, which must also be one of 'env', 'ini', or 'properties'.
A boolean indicating whether the keys in the output should be sorted.
The configuration text to be converted.
The configuration document from which entries will be removed.
The specific key of the entry to be removed from the configuration document.
The section of the configuration document where the key resides.
The configuration document from which entries will be removed.
A boolean indicating whether any entries matching the specified section and key were found and removed.
The input text that needs to be classified based on its format.
The determined format of the input text, which can be 'env', 'ini', 'properties', or 'unknown'.
A numerical value representing the confidence level of the classification, with higher values indicating greater certainty.
A list of potential format candidates along with their respective confidence scores.
No fields
No fields
No fields
The ConfigDocument containing the configuration entries to be searched.
The specific key within the section whose value is being requested.
The section in the ConfigDocument where the key is located.
The value associated with the specified (section, key) pair, or an empty string if not found.
A boolean indicating whether the specified (section, key) entry was found in the ConfigDocument.
The base configuration document that provides the initial set of entries.
The overlay configuration document that contains entries that may override or append to the base document.
The .env formatted text input that contains key-value pairs to be parsed.
The raw INI text input that needs to be parsed.
The raw Java .properties text input to be parsed.
The ConfigDocument to be serialized into .env format.
The ConfigDocument that contains the configuration data to be serialized.
The input configuration document that contains the current settings.
The specific key within the section that is being updated.
The new value to be set for the specified key in the configuration.
The section of the configuration where the key-value pair is located.
The input configuration document that contains the current settings.
The input text that needs to be validated against the specified format.
The format type to validate against, which can be 'env', 'ini', or 'properties'.
Indicates whether the input text is valid according to the specified format.
Provides an explanation if the input text is not valid, including details about the error.
The line number where the error occurred, applicable for 'ini' format validation.
The column number where the error occurred, applicable for 'ini' format validation.
Use as a tool in any AI agent
christiangeorgelucas/config-tools is callable as a tool from any MCP client (Claude, Cursor, or your own agent) via Axiom's hosted MCP server — no install, no download. Add the server, then your agent can search and invoke it, or pin it as a typed tool.
claude mcp add --transport http axiom https://api.axiomide.com/mcp --header "Authorization: Bearer YOUR_AXIOM_API_KEY"Replace YOUR_AXIOM_API_KEY with a key you create in Console → API Keys.
MCP server setup & other clients →Use this package
Sign in to import christiangeorgelucas/config-tools into the graph editor — drop it into a flow and hit run. Or call it directly via the API Reference with your API key.
Sign in to useOpen editor