In this tutorial we are going to build dbf, a console tool written in Visual FoxPro that inspects and exports tables without opening VFP:

dbf info customers.dbf
dbf campos customers.dbf --format json
dbf exportar customers.dbf clientes.csv

It has three commands, help that writes itself, errors that explain what is going on and exit codes a script can read. You hand it out by copying a folder, onto a machine without Visual FoxPro. And all of the code is one 76-line VFP .prg.

I built it in Spanish, so the command names (campos is “fields”, exportar is “export”), the messages and the screenshots are in Spanish. FoxForge itself speaks your language: I give the menu names in English. The names of your commands are yours to choose.

We work with customers.dbf, the customers table from the Northwind sample data that ships with VFP, so you can repeat what you see in the screenshots as it is. dbf only reads: it never writes to the table.

If the console feels far away, before you start there is the CLIs for Fox developers course: what a command is, what arguments and options are and what an exit code is. Here I take all of that as known.

What you need

  • Visual FoxPro 9 (or VFP Advanced).
  • FoxCli installed. It brings FoxForge, the workshop that plugs into the VFP IDE. Without a license it works the same; we see the difference at the end.
  • Windows 10 or 11.

Open VFP as usual, without “Run as administrator”. When you build as administrator, VFP registers the component in HKLM, and that registration gets in the way of later builds. And do not use the Build button of the Project Manager: build with Ctrl+F7, which is what leaves the CLI complete.

Step 1. Load FoxForge

In the VFP Command window:

Loading FoxForge in the IDE
DO "C:\Program Files (x86)\FoxForge\FoxForge.app"

Change the path if you installed FoxForge in another folder. A new menu appears on the VFP menu bar, FoxForge, and everything else is done from there.

Step 2. Create the project

FoxForge > New project > Console application (CLI). In the wizard:

  • Name: dbf. It is the program’s name, the one you type in the console.
  • Folder: your projects folder. I used C:\a1\tutorial-b.
  • Minimal template, no comments: ticked. The other template brings the code explained line by line, which is good to read once, but today we are writing our own.

The FoxForge "New console application" wizard (in Spanish), with the name dbf, the folder and the minimal template ticked

Press Create. FoxForge creates the project and opens it in the Project Manager:

dbf\
├── dbf.pjx          the VFP project
├── foxforge.json    marks it as a CLI project
├── pack.ps1         packs what you hand out
├── src\
│   ├── main.prg     your commands: the only file you edit
│   ├── foxcli.h     named exit codes
│   └── foxcli.prg   the base class, which belongs to FoxCli
└── dist\            the .exe lands here when you build

Step 3. What the template brings

Open src\main.prg:

main.prg just created, with the minimal template
#INCLUDE foxcli.h

*!* dbf: describe aquí lo que hace tu CLI
DEFINE CLASS DbfCommand AS FoxCliCommand OF foxcli.prg OLEPUBLIC

    *!* Saluda a alguien
    *!* @tcNombre  -n  A quién saludar
    PROCEDURE Saludar(tcNombre AS String)
        Console.WriteLine("Hola " + tcNombre + "!")
    ENDPROC

ENDDEFINE

That is enough to see how it all works:

  • Every public method of the class is a command. Saludar (“greet”) is used as dbf saludar.
  • Every parameter is an option, named without its prefix: tcNombre is --nombre. The type comes from the AS.
  • The *!* block above it is the documentation. The first line is the command’s description, and each @ line annotates a parameter: -n gives it a short alias and the rest is its description. The help comes from there.
  • Console is the console. Console.WriteLine() writes to the output and Console.Error() to the error channel.

Step 4. Write the commands

Replace the contents of main.prg with this:

dbf's main.prg: three commands to inspect and export tables
#INCLUDE foxcli.h

*!* dbf: mira y exporta tablas de VFP desde la consola, sin abrir Visual FoxPro
*!* @exit 10 La tabla no existe
DEFINE CLASS DbfCommand AS FoxCliCommand OF foxcli.prg OLEPUBLIC

	*!* Cuántos registros y campos tiene una tabla
	*!* @tcTabla <>  La tabla (.dbf)
	*!* @example dbf info customers.dbf
	PROCEDURE Info(tcTabla AS String)
		IF !THIS.Abrir(tcTabla)
			RETURN EXIT_NO_TABLE
		ENDIF
		Console.WriteLine("Registros:  " + TRANSFORM(RECCOUNT()))
		Console.WriteLine("Campos:     " + TRANSFORM(FCOUNT()))
		Console.Write("Modificada: ")
		Console.WriteLine(LUPDATE())
		USE
	ENDPROC

	*!* La estructura de una tabla: nombre, tipo y ancho de cada campo
	*!* @tcTabla <>  La tabla (.dbf)
	*!* @example dbf campos customers.dbf --format json
	PROCEDURE Campos(tcTabla AS String)
		LOCAL laCampos[1], lnCampos, lnI, lcJson
		IF !THIS.Abrir(tcTabla)
			RETURN EXIT_NO_TABLE
		ENDIF
		lnCampos = AFIELDS(laCampos)
		USE
		IF Console.IsJson()
			lcJson = ""
			FOR lnI = 1 TO lnCampos
				lcJson = lcJson + IIF(lnI > 1, ",", "") + ;
					'{"nombre":"' + LOWER(laCampos[lnI, 1]) + '","tipo":"' + laCampos[lnI, 2] + ;
					'","ancho":' + TRANSFORM(laCampos[lnI, 3]) + '}'
			ENDFOR
			Console.WriteLine("[" + lcJson + "]")
		ELSE
			FOR lnI = 1 TO lnCampos
				Console.WriteLine(PADR(LOWER(laCampos[lnI, 1]), 14) + laCampos[lnI, 2] + ;
					STR(laCampos[lnI, 3], 5))
			ENDFOR
		ENDIF
	ENDPROC

	*!* Copia una tabla a un fichero CSV o XLS
	*!* @tcTabla   <>             La tabla (.dbf)
	*!* @tcDestino <>             El fichero que se crea
	*!* @tcFormato :csv|xls =csv  Formato del fichero
	*!* @example dbf exportar customers.dbf clientes.csv
	*!* @example dbf exportar customers.dbf clientes.xls --formato xls
	PROCEDURE Exportar(tcTabla AS String, tcDestino AS String, tcFormato AS String)
		IF !THIS.Abrir(tcTabla)
			RETURN EXIT_NO_TABLE
		ENDIF
		IF tcFormato == "xls"
			COPY TO (tcDestino) TYPE XL5
		ELSE
			COPY TO (tcDestino) TYPE CSV
		ENDIF
		Console.Error(TRANSFORM(RECCOUNT()) + " registros copiados a " + tcDestino)
		USE
	ENDPROC

	*-- Abre la tabla solo para leer. Si no existe, lo dice por el canal de error.
	PROTECTED FUNCTION Abrir(tcTabla)
		IF !FILE(FORCEEXT(tcTabla, "dbf"))
			Console.Error("dbf: no encuentro la tabla " + tcTabla)
			RETURN .F.
		ENDIF
		USE (FORCEEXT(tcTabla, "dbf")) AGAIN SHARED NOUPDATE ALIAS tabla
		RETURN .T.
	ENDFUNC

ENDDEFINE

And add your own exit code at the end of src\foxcli.h:

dbf's exit code, in foxcli.h
#DEFINE EXIT_NO_TABLE 10    && la tabla no existe

What to look at in that code:

  • <> makes a positional. @tcTabla <> says the table is written with no name in front: dbf info customers.dbf, and not dbf info --tabla customers.dbf.
  • :csv|xls =csv is a closed list with a default value. --formato only accepts csv or xls, and if it is not given it is csv. FoxCli validates it before your code is reached.
  • What the method returns is the exit code. With no RETURN, the command ends with 0. RETURN EXIT_NO_TABLE ends with 10, and the @exit 10 above documents it. Yours go from 10 to 125: the low numbers belong to FoxCli.
  • Console.IsJson() tells you whether the caller asked for --format json. You do not declare that option: FoxCli brings it in every command.
  • The result goes to the output and messages to the error channel. exportar reports how many records it copied with Console.Error(), so if someone redirects its output to a file, the message does not get mixed in.
  • Abrir (“open”) is PROTECTED: a helper method like any other, which does not show up in the list of commands. That is why its comment uses the usual *--: *!* is for what appears in the CLI’s help, and a helper method is never called from the console.
  • Console.WriteLine(LUPDATE()) writes the date in ISO format (2004-12-09) on any machine, without depending on SET DATE. A CLI’s output is read by other programs, and it has to be the same everywhere.

Step 5. Build

FoxForge > Current project > Build CLI, or Ctrl+F7. Without a FoxCli license, this notice appears:

FoxForge's notice after building (in Spanish): CLI built at C:\a1\tutorial-b\dbf\dist\dbf.exe; without a FoxCli license it comes out in evaluation and writes one line to stderr

You now have dist\dbf.exe. Along the way, FoxForge has generated the documentation in docs\dbf.md, with each command’s description taken from the *!* blocks.

Step 6. Try it from the IDE

Before opening a console, you can try it without leaving VFP. FoxForge > Current project > Arguments…, or Shift+F8, and type the line as you would in a console, without the program’s name:

FoxForge's arguments dialog (in Spanish), with info and the full path of customers.dbf, and below it the command that will run

It runs inside dist\, so the full path of the table goes here. Run (or Ctrl+F8 next time, since it remembers the arguments) opens a console with the result, and the exit code underneath:

A console with the output of dbf info: 91 records, 11 fields, modified on 2004-12-09, FoxCli's evaluation line and exit 0

If something fails, FoxForge > Current project > Test bench runs a command from your source, with the VFP debugger at hand and without building.

Step 7. Hand it out and use it from the console

In the project folder, pack.ps1 builds a zip with what is needed to run the CLI and nothing else. From a console in that folder:

powershell -ExecutionPolicy Bypass -File pack.ps1
9 ficheros -> C:\a1\tutorial-b\dbf\dbf-dist.zip (3.634.677 bytes)
Se despliega descomprimiendo: no hace falta registrar nada ni ser administrador.

Those 9 files are your CLI and the VFP 9 runtime, so it works on a machine without Visual FoxPro. Unzip them wherever you like (I use a tools folder) and it is installed. Nothing gets registered, so there is nothing to uninstall either: you delete the folder.

To leave the VFP folder alone, copy the Northwind data to your test folder. Careful: the whole folder, because customers.dbf belongs to the northwind.dbc database:

The console copying the Northwind folder with xcopy to C:\pruebas-cli: 33 files copied

Go into the copy and add dbf’s folder to that console’s PATH, so you can type dbf without its path:

cd C:\pruebas-cli\northwind
set PATH=%PATH%;C:\herramientas\dbf

(Change C:\herramientas\dbf to the folder where you unzipped it. That set only holds for that window.)

You wrote the help without noticing, in the *!* blocks:

dbf's general help: the CLI's description, its usage and the list of commands info, campos, exportar and describe

describe also comes with FoxCli: it dumps every command with its options as JSON, so an AI agent can discover the CLI without reading anything else.

If an argument is missing, dbf never reaches your code: it says what is missing, shows the command’s help and exits with 2. With the table, it gives the result:

dbf info with no arguments: missing positional arguments and the command's help; then dbf info customers.dbf with 91 records and 11 fields

campos shows the structure as a table you read on screen:

dbf campos customers.dbf: the list of fields with their type and width

And with --format json, as one line another program can read:

dbf campos customers.dbf --format json
[{"nombre":"customerid","tipo":"C","ancho":5},{"nombre":"companyname","tipo":"C","ancho":40},{"nombre":"contactname","tipo":"C","ancho":30},...,{"nombre":"fax","tipo":"C","ancho":24}]

exportar leaves the file and reports on the error channel. With a table that does not exist, it exits with the 10 we declared:

dbf exportar to ..\clientes.csv: 91 records copied and code 0; dbf info facturas.dbf: table not found and code 10

The CSV keeps its accented letters (Avda. de la Constitución 2222, México D.F.), because VFP writes it with the table’s code page.

And a format that is not on the list never reaches your code: FoxCli says which ones are valid, shows the help of exportar and exits with 2.

dbf exportar with the txt format: not valid, the values are csv and xls, the command's help and code 2

A table away from its database. If you copy only customers.dbf to another folder, without northwind.dbc, VFP will try to ask you where the database is. In a CLI there is nobody to ask, so dbf exits with code 1 and VFP’s error on the error channel (“User-interface operation not allowed at this time”, error 2031). It does not hang waiting for an answer: FoxCli never leaves a dialog open.

Step 8. The license

Without a license, the CLI works the same and never expires. The only difference is the line you have seen at the end of every output (on mine, in Spanish):

Hecho con FoxCli (evaluación) - irwinrodriguez.dev/foxcli

It goes to the error channel, so it does not end up in a CSV or a JSON even if you redirect the output. With a FoxCli license loaded in FoxForge > FoxCli license…, Ctrl+F7 seals the CLI with your name: the line disappears, and --version says who it is licensed to.

dbf --version
dbf (FoxCli 1.3.0, con licencia de Irwin Rodríguez)

You hand out the CLIs you build for free and to whoever you like: the license belongs to the developer who builds, not to each copy.

What next

  • The FoxCli guide is in FoxForge > Help > FoxCli guide: reading input, dates, the output format, the HELP clause and everything that did not come up today.
  • Your CLI’s documentation is in docs\dbf.md, and it is regenerated on every build: it works as a README as it is.
  • A big example: FoxPack, the library manager for VFP projects, is a CLI built with FoxCli, and its source code is published.
  • Ideas to keep going with dbf: a contar (“count”) command with a filter (--donde "country = 'Spain'"), or letting exportar take --campos to choose the columns.